fix: decouple manual tidy from vehicle assignment
This commit is contained in:
@@ -31,7 +31,7 @@
|
|||||||
- Produces: `CapacityResult calculate(List<VehicleSnapshot> vehicles, int activeManualWorkflows, int maxConcurrency)`.
|
- Produces: `CapacityResult calculate(List<VehicleSnapshot> vehicles, int activeManualWorkflows, int maxConcurrency)`.
|
||||||
- Produces: `int AlgorithmProperties.getTidyManualMaxConcurrency()` and `void setTidyManualMaxConcurrency(int value)`.
|
- Produces: `int AlgorithmProperties.getTidyManualMaxConcurrency()` and `void setTidyManualMaxConcurrency(int value)`.
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing capacity and default tests**
|
- [x] **Step 1: Write failing capacity and default tests**
|
||||||
|
|
||||||
Replace assigned-vehicle assertions with literal cases proving:
|
Replace assigned-vehicle assertions with literal cases proving:
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ assertThat(capacity.calculate(unavailableVehicles, 0, 3).newSlots()).isZero();
|
|||||||
assertThat(new AlgorithmProperties().getTidyManualMaxConcurrency()).isEqualTo(3);
|
assertThat(new AlgorithmProperties().getTidyManualMaxConcurrency()).isEqualTo(3);
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests and verify RED**
|
- [x] **Step 2: Run tests and verify RED**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ mvn -pl ruoyi-admin -am -Dtest=TidyVehicleCapacityTest,AlgorithmConfigurationTes
|
|||||||
|
|
||||||
Expected: test compilation fails because the new capacity signature and property getter do not exist.
|
Expected: test compilation fails because the new capacity signature and property getter do not exist.
|
||||||
|
|
||||||
- [ ] **Step 3: Implement the minimal capacity and configuration changes**
|
- [x] **Step 3: Implement the minimal capacity and configuration changes**
|
||||||
|
|
||||||
Implement:
|
Implement:
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ Add `tidyManualMaxConcurrency = 3` plus getter/setter to `AlgorithmProperties`,
|
|||||||
tidy-manual-max-concurrency: ${FORTUCKY_TIDY_MANUAL_MAX_CONCURRENCY:3}
|
tidy-manual-max-concurrency: ${FORTUCKY_TIDY_MANUAL_MAX_CONCURRENCY:3}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: Run focused tests and verify GREEN**
|
- [x] **Step 4: Run focused tests and verify GREEN**
|
||||||
|
|
||||||
Run the command from Step 2. Expected: all specified tests pass.
|
Run the command from Step 2. Expected: all specified tests pass.
|
||||||
|
|
||||||
@@ -85,11 +85,11 @@ Run the command from Step 2. Expected: all specified tests pass.
|
|||||||
- Consumes: `TidyVehicleCapacity.calculate(vehicles, activeWorkflows, properties.getTidyManualMaxConcurrency())`.
|
- Consumes: `TidyVehicleCapacity.calculate(vehicles, activeWorkflows, properties.getTidyManualMaxConcurrency())`.
|
||||||
- Removes: `selectActiveManualTidyVehicleIds` and `assignTidyWorkflowVehicle` runtime mapper operations.
|
- Removes: `selectActiveManualTidyVehicleIds` and `assignTidyWorkflowVehicle` runtime mapper operations.
|
||||||
|
|
||||||
- [ ] **Step 1: Write a failing scheduler behavior test**
|
- [x] **Step 1: Write a failing scheduler behavior test**
|
||||||
|
|
||||||
Create a scheduler with mocked database/external boundaries. Return two healthy idle vehicles, one active workflow, maximum concurrency `3`, and two planner candidates. Assert that `TidyWorkflowTransaction.start` receives both candidates in planner order. The existing implementation starts only one because it subtracts the active workflow from the already-idle snapshot.
|
Create a scheduler with mocked database/external boundaries. Return two healthy idle vehicles, one active workflow, maximum concurrency `3`, and two planner candidates. Assert that `TidyWorkflowTransaction.start` receives both candidates in planner order. The existing implementation starts only one because it subtracts the active workflow from the already-idle snapshot.
|
||||||
|
|
||||||
- [ ] **Step 2: Run the scheduler test and verify RED**
|
- [x] **Step 2: Run the scheduler test and verify RED**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ mvn -pl ruoyi-admin -am -Dtest=ManualTidySchedulerTest -Dsurefire.failIfNoSpecif
|
|||||||
|
|
||||||
Expected: FAIL because only one workflow is started or compilation fails until the scheduler adopts the new capacity signature.
|
Expected: FAIL because only one workflow is started or compilation fails until the scheduler adopts the new capacity signature.
|
||||||
|
|
||||||
- [ ] **Step 3: Implement scheduler ownership changes**
|
- [x] **Step 3: Implement scheduler ownership changes**
|
||||||
|
|
||||||
Remove assigned-vehicle collection and the vehicle iterator. Calculate slots with the configured ceiling, iterate only planner candidates, and call:
|
Remove assigned-vehicle collection and the vehicle iterator. Calculate slots with the configured ceiling, iterate only planner candidates, and call:
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ Do not update a workflow vehicle. Log a candidate-oriented warning if workflow c
|
|||||||
|
|
||||||
Remove the two unused vehicle-binding methods from `WmsAlgorithmMapper` and their MyBatis XML statements. Leave the schema column and diagnostics unchanged.
|
Remove the two unused vehicle-binding methods from `WmsAlgorithmMapper` and their MyBatis XML statements. Leave the schema column and diagnostics unchanged.
|
||||||
|
|
||||||
- [ ] **Step 4: Run focused tidy tests and verify GREEN**
|
- [x] **Step 4: Run focused tidy tests and verify GREEN**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -130,13 +130,13 @@ Expected: all specified tests pass.
|
|||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Produces: deployable `ruoyi-admin/target/ruoyi-admin.jar` and an updated handoff record.
|
- Produces: deployable `ruoyi-admin/target/ruoyi-admin.jar` and an updated handoff record.
|
||||||
|
|
||||||
- [ ] **Step 1: Run source and diff checks**
|
- [x] **Step 1: Run source and diff checks**
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Run the full reactor tests**
|
- [x] **Step 2: Run the full reactor tests**
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
mvn test
|
mvn test
|
||||||
@@ -144,7 +144,7 @@ mvn test
|
|||||||
|
|
||||||
Expected: all reactor tests pass with zero failures and errors.
|
Expected: all reactor tests pass with zero failures and errors.
|
||||||
|
|
||||||
- [ ] **Step 3: Build the deployment artifact**
|
- [x] **Step 3: Build the deployment artifact**
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
mvn -DskipTests package
|
mvn -DskipTests package
|
||||||
@@ -152,10 +152,10 @@ mvn -DskipTests package
|
|||||||
|
|
||||||
Expected: all reactor modules report `SUCCESS`.
|
Expected: all reactor modules report `SUCCESS`.
|
||||||
|
|
||||||
- [ ] **Step 4: Update the handoff record**
|
- [x] **Step 4: Update the handoff record**
|
||||||
|
|
||||||
Record the capacity-ownership change, verification totals, JAR size, and current SHA-256. Preserve the existing statement that an actual-WCS read-only snapshot smoke test remains a deployment step.
|
Record the capacity-ownership change, verification totals, JAR size, and current SHA-256. Preserve the existing statement that an actual-WCS read-only snapshot smoke test remains a deployment step.
|
||||||
|
|
||||||
- [ ] **Step 5: Review and commit exact files**
|
- [x] **Step 5: Review and commit exact files**
|
||||||
|
|
||||||
Run `git diff --cached --check`, inspect the complete staged diff, and commit only the implementation, tests, configuration, spec/plan files that belong to this change. Verify `git show --name-status HEAD` after committing.
|
Run `git diff --cached --check`, inspect the complete staged diff, and commit only the implementation, tests, configuration, spec/plan files that belong to this change. Verify `git show --name-status HEAD` after committing.
|
||||||
|
|||||||
@@ -30,12 +30,16 @@ On every manual tidy reconciliation cycle, WMS first performs workflow recovery
|
|||||||
|
|
||||||
WMS asks the planner for candidates only when at least one slot exists. It starts at most the calculated number of workflows in planner order. No candidate is paired with a snapshot row, and no `vehicle_id` update is issued. WCS later receives the ordinary move job and chooses the actual vehicle using its existing dispatch rules.
|
WMS asks the planner for candidates only when at least one slot exists. It starts at most the calculated number of workflows in planner order. No candidate is paired with a snapshot row, and no `vehicle_id` update is issued. WCS later receives the ordinary move job and chooses the actual vehicle using its existing dispatch rules.
|
||||||
|
|
||||||
|
The manual scheduling cycle is one database transaction. `TidyModeService.reconcile` reads the warehouse's `alg.tidy_mode` row with `UPDLOCK, HOLDLOCK`; keeping that lock until the scheduling transaction commits serializes capacity calculation and workflow creation for the same warehouse across WMS instances. Different warehouses continue to use different mode rows.
|
||||||
|
|
||||||
## Failure Handling
|
## Failure Handling
|
||||||
|
|
||||||
If the WCS snapshot URL is absent, the request fails, the response is empty, or the response cannot be parsed, the snapshot client returns an empty list. The calculated idle count is then zero, so WMS creates no new manual tidy workflows during that cycle. Existing workflows and WCS queues continue independently and can still complete.
|
If the WCS snapshot URL is absent, the request fails, the response is empty, or the response cannot be parsed, the snapshot client returns an empty list. The calculated idle count is then zero, so WMS creates no new manual tidy workflows during that cycle. Existing workflows and WCS queues continue independently and can still complete.
|
||||||
|
|
||||||
This fail-closed behavior avoids building an unbounded WCS queue when real-time fleet availability is unknown. Operators can distinguish this condition through the existing snapshot warning log.
|
This fail-closed behavior avoids building an unbounded WCS queue when real-time fleet availability is unknown. Operators can distinguish this condition through the existing snapshot warning log.
|
||||||
|
|
||||||
|
If any workflow creation fails, the scheduler logs the candidate type and propagates the exception. The complete scheduling batch rolls back so a partial batch cannot consume capacity outside the locked count. The next scheduled cycle can retry from a consistent database state.
|
||||||
|
|
||||||
## Compatibility
|
## Compatibility
|
||||||
|
|
||||||
The existing `alg.tidy_workflow.vehicle_id` column, SQL Server index include, and diagnostic projection remain in place for schema and historical-data compatibility. Runtime code stops reading or writing the column for new manual tidy scheduling. No WCS API or database migration is required.
|
The existing `alg.tidy_workflow.vehicle_id` column, SQL Server index include, and diagnostic projection remain in place for schema and historical-data compatibility. Runtime code stops reading or writing the column for new manual tidy scheduling. No WCS API or database migration is required.
|
||||||
@@ -64,5 +68,7 @@ The implementation must prove the following behavior with tests written before p
|
|||||||
4. Offline, unhealthy, and busy vehicles do not produce slots.
|
4. Offline, unhealthy, and busy vehicles do not produce slots.
|
||||||
5. Manual scheduling starts workflows in planner order without reading or writing vehicle bindings.
|
5. Manual scheduling starts workflows in planner order without reading or writing vehicle bindings.
|
||||||
6. The default configured maximum is `3`.
|
6. The default configured maximum is `3`.
|
||||||
|
7. The scheduling cycle has a rollback transaction that holds the existing warehouse mode-row lock.
|
||||||
|
8. Workflow creation failures propagate and prevent later candidates in the same batch from starting.
|
||||||
|
|
||||||
Focused tests must pass before running the full Maven reactor test suite and package build.
|
Focused tests must pass before running the full Maven reactor test suite and package build.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public class AlgorithmProperties {
|
|||||||
private long tidyAutoScanMs = 600_000L;
|
private long tidyAutoScanMs = 600_000L;
|
||||||
private long tidyReconcileMs = 5_000L;
|
private long tidyReconcileMs = 5_000L;
|
||||||
private int tidyClaimLeaseSeconds = 60;
|
private int tidyClaimLeaseSeconds = 60;
|
||||||
|
private int tidyManualMaxConcurrency = 3;
|
||||||
private String tidyWcsSnapshotUrl = "http://localhost:8081/wcs2Wms/api/tidyVehicleSnapshot";
|
private String tidyWcsSnapshotUrl = "http://localhost:8081/wcs2Wms/api/tidyVehicleSnapshot";
|
||||||
private String replayRunId = "";
|
private String replayRunId = "";
|
||||||
private String replayDatabase = "";
|
private String replayDatabase = "";
|
||||||
@@ -62,6 +63,8 @@ public class AlgorithmProperties {
|
|||||||
public void setTidyReconcileMs(long value) { this.tidyReconcileMs = value; }
|
public void setTidyReconcileMs(long value) { this.tidyReconcileMs = value; }
|
||||||
public int getTidyClaimLeaseSeconds() { return tidyClaimLeaseSeconds; }
|
public int getTidyClaimLeaseSeconds() { return tidyClaimLeaseSeconds; }
|
||||||
public void setTidyClaimLeaseSeconds(int value) { this.tidyClaimLeaseSeconds = value; }
|
public void setTidyClaimLeaseSeconds(int value) { this.tidyClaimLeaseSeconds = value; }
|
||||||
|
public int getTidyManualMaxConcurrency() { return tidyManualMaxConcurrency; }
|
||||||
|
public void setTidyManualMaxConcurrency(int value) { this.tidyManualMaxConcurrency = value; }
|
||||||
public String getTidyWcsSnapshotUrl() { return tidyWcsSnapshotUrl; }
|
public String getTidyWcsSnapshotUrl() { return tidyWcsSnapshotUrl; }
|
||||||
public void setTidyWcsSnapshotUrl(String value) { this.tidyWcsSnapshotUrl = value; }
|
public void setTidyWcsSnapshotUrl(String value) { this.tidyWcsSnapshotUrl = value; }
|
||||||
public String getReplayRunId() { return replayRunId; }
|
public String getReplayRunId() { return replayRunId; }
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class AlgorithmConfigurationTest {
|
|||||||
assertThat(properties.getTidyAutoScanMs()).isEqualTo(600_000L);
|
assertThat(properties.getTidyAutoScanMs()).isEqualTo(600_000L);
|
||||||
assertThat(properties.getTidyReconcileMs()).isEqualTo(5_000L);
|
assertThat(properties.getTidyReconcileMs()).isEqualTo(5_000L);
|
||||||
assertThat(properties.getTidyClaimLeaseSeconds()).isEqualTo(60);
|
assertThat(properties.getTidyClaimLeaseSeconds()).isEqualTo(60);
|
||||||
|
assertThat(properties.getTidyManualMaxConcurrency()).isEqualTo(3);
|
||||||
assertThat(properties.getTidyWcsSnapshotUrl()).contains("tidyVehicleSnapshot");
|
assertThat(properties.getTidyWcsSnapshotUrl()).contains("tidyVehicleSnapshot");
|
||||||
assertThat(properties.getReplayRunId()).isBlank();
|
assertThat(properties.getReplayRunId()).isBlank();
|
||||||
assertThat(properties.getReplayDatabase()).isBlank();
|
assertThat(properties.getReplayDatabase()).isBlank();
|
||||||
|
|||||||
@@ -74,11 +74,6 @@ public interface WmsAlgorithmMapper {
|
|||||||
|
|
||||||
int countActiveManualTidy(@Param("warehouseId") String warehouseId);
|
int countActiveManualTidy(@Param("warehouseId") String warehouseId);
|
||||||
|
|
||||||
List<String> selectActiveManualTidyVehicleIds(@Param("warehouseId") String warehouseId);
|
|
||||||
|
|
||||||
int assignTidyWorkflowVehicle(@Param("workflowId") String workflowId,
|
|
||||||
@Param("vehicleId") String vehicleId);
|
|
||||||
|
|
||||||
List<InboundTaskItem> selectInboundTaskItems(@Param("jobId") String jobId);
|
List<InboundTaskItem> selectInboundTaskItems(@Param("jobId") String jobId);
|
||||||
|
|
||||||
int claimInboundSpace(@Param("reservationId") String reservationId,
|
int claimInboundSpace(@Param("reservationId") String reservationId,
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.Iterator;
|
import java.util.Iterator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class ManualTidyScheduler {
|
public class ManualTidyScheduler {
|
||||||
@@ -44,6 +43,7 @@ public class ManualTidyScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Scheduled(fixedDelayString = "${fortucky.algorithm.tidy-reconcile-ms:5000}")
|
@Scheduled(fixedDelayString = "${fortucky.algorithm.tidy-reconcile-ms:5000}")
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void reconcileAndFill() {
|
public void reconcileAndFill() {
|
||||||
if (properties.getMode() != com.ruoyi.algorithm.integration.SchedulingMode.ACTIVE
|
if (properties.getMode() != com.ruoyi.algorithm.integration.SchedulingMode.ACTIVE
|
||||||
|| !properties.isDatabaseWritesEnabled()) return;
|
|| !properties.isDatabaseWritesEnabled()) return;
|
||||||
@@ -52,26 +52,21 @@ public class ManualTidyScheduler {
|
|||||||
if (mode.effectiveState() != TidyEffectiveState.TIDY_ACTIVE || !mode.requestedEnabled()) return;
|
if (mode.effectiveState() != TidyEffectiveState.TIDY_ACTIVE || !mode.requestedEnabled()) return;
|
||||||
|
|
||||||
List<VehicleSnapshot> vehicles = vehicleClient.fetch();
|
List<VehicleSnapshot> vehicles = vehicleClient.fetch();
|
||||||
Set<String> assigned = new HashSet<>(mapper.selectActiveManualTidyVehicleIds(warehouseId));
|
|
||||||
TidyVehicleCapacity.CapacityResult result = capacity.calculate(
|
TidyVehicleCapacity.CapacityResult result = capacity.calculate(
|
||||||
vehicles, mapper.countActiveManualTidy(warehouseId), assigned);
|
vehicles, mapper.countActiveManualTidy(warehouseId),
|
||||||
|
properties.getTidyManualMaxConcurrency());
|
||||||
if (result.newSlots() == 0) return;
|
if (result.newSlots() == 0) return;
|
||||||
|
|
||||||
Iterator<VehicleSnapshot> vehicleIterator = vehicles.stream()
|
|
||||||
.filter(VehicleSnapshot::availableForManualTidy)
|
|
||||||
.filter(vehicle -> !assigned.contains(vehicle.vehicleId()))
|
|
||||||
.iterator();
|
|
||||||
Iterator<TidyCandidate> candidateIterator = facade.planTidy(warehouseId, Instant.now())
|
Iterator<TidyCandidate> candidateIterator = facade.planTidy(warehouseId, Instant.now())
|
||||||
.candidates().iterator();
|
.candidates().iterator();
|
||||||
int remaining = result.newSlots();
|
int remaining = result.newSlots();
|
||||||
while (remaining-- > 0 && vehicleIterator.hasNext() && candidateIterator.hasNext()) {
|
while (remaining-- > 0 && candidateIterator.hasNext()) {
|
||||||
VehicleSnapshot vehicle = vehicleIterator.next();
|
|
||||||
TidyCandidate candidate = candidateIterator.next();
|
TidyCandidate candidate = candidateIterator.next();
|
||||||
try {
|
try {
|
||||||
var workflowId = workflows.start(TidyOrigin.MANUAL_TIDY, warehouseId, candidate);
|
workflows.start(TidyOrigin.MANUAL_TIDY, warehouseId, candidate);
|
||||||
mapper.assignTidyWorkflowVehicle(workflowId.toString(), vehicle.vehicleId());
|
|
||||||
} catch (RuntimeException ex) {
|
} catch (RuntimeException ex) {
|
||||||
log.warn("Unable to start manual tidy workflow for vehicle {}", vehicle.vehicleId(), ex);
|
log.warn("Unable to start manual tidy workflow for candidate {}", candidate.type(), ex);
|
||||||
|
throw ex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
package com.ruoyi.algorithm.wms.tidy;
|
package com.ruoyi.algorithm.wms.tidy;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
public final class TidyVehicleCapacity {
|
public final class TidyVehicleCapacity {
|
||||||
public CapacityResult calculate(List<VehicleSnapshot> vehicles, int activeManualWorkflows,
|
public CapacityResult calculate(List<VehicleSnapshot> vehicles, int activeManualWorkflows,
|
||||||
Set<String> assignedManualVehicleIds) {
|
int maxConcurrency) {
|
||||||
int effective = (int) vehicles.stream()
|
int effective = (int) vehicles.stream()
|
||||||
.filter(VehicleSnapshot::availableForManualTidy)
|
.filter(VehicleSnapshot::availableForManualTidy)
|
||||||
.count();
|
.count();
|
||||||
int assigned = assignedManualVehicleIds == null ? 0 : (int) assignedManualVehicleIds.stream()
|
if (maxConcurrency <= 0) {
|
||||||
.filter(id -> vehicles.stream().anyMatch(v -> v.vehicleId().equals(id)
|
return new CapacityResult(effective, 0);
|
||||||
&& v.online() && v.healthy()))
|
}
|
||||||
.count();
|
int remainingByLimit = Math.max(0, maxConcurrency - Math.max(0, activeManualWorkflows));
|
||||||
int available = Math.max(0, effective - Math.max(activeManualWorkflows, assigned));
|
return new CapacityResult(effective, Math.min(effective, remainingByLimit));
|
||||||
return new CapacityResult(effective, available);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public record CapacityResult(int effectiveCapacity, int newSlots) { }
|
public record CapacityResult(int effectiveCapacity, int newSlots) { }
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ fortucky:
|
|||||||
tidy-auto-scan-ms: ${FORTUCKY_TIDY_AUTO_SCAN_MS:600000}
|
tidy-auto-scan-ms: ${FORTUCKY_TIDY_AUTO_SCAN_MS:600000}
|
||||||
tidy-reconcile-ms: ${FORTUCKY_TIDY_RECONCILE_MS:5000}
|
tidy-reconcile-ms: ${FORTUCKY_TIDY_RECONCILE_MS:5000}
|
||||||
tidy-claim-lease-seconds: ${FORTUCKY_TIDY_CLAIM_LEASE_SECONDS:60}
|
tidy-claim-lease-seconds: ${FORTUCKY_TIDY_CLAIM_LEASE_SECONDS:60}
|
||||||
|
tidy-manual-max-concurrency: ${FORTUCKY_TIDY_MANUAL_MAX_CONCURRENCY:3}
|
||||||
tidy-wcs-snapshot-url: ${FORTUCKY_TIDY_WCS_SNAPSHOT_URL:http://localhost:8081/wcs2Wms/api/tidyVehicleSnapshot}
|
tidy-wcs-snapshot-url: ${FORTUCKY_TIDY_WCS_SNAPSHOT_URL:http://localhost:8081/wcs2Wms/api/tidyVehicleSnapshot}
|
||||||
replay-run-id: ${FT546_REPLAY_RUN_ID:}
|
replay-run-id: ${FT546_REPLAY_RUN_ID:}
|
||||||
replay-database: ${FT546_REPLAY_DATABASE:}
|
replay-database: ${FT546_REPLAY_DATABASE:}
|
||||||
|
|||||||
@@ -245,21 +245,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||||||
)
|
)
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="selectActiveManualTidyVehicleIds" resultType="string">
|
|
||||||
select vehicle_id
|
|
||||||
from alg.tidy_workflow
|
|
||||||
where warehouse_id = #{warehouseId}
|
|
||||||
and origin = 'MANUAL_TIDY'
|
|
||||||
and vehicle_id is not null
|
|
||||||
and status in ('CLAIMED', 'WAITING_DISPATCH', 'DISPATCHED', 'RUNNING', 'PAUSED_RECOVERY')
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<update id="assignTidyWorkflowVehicle">
|
|
||||||
update alg.tidy_workflow
|
|
||||||
set vehicle_id = #{vehicleId}, updated_at = SYSUTCDATETIME()
|
|
||||||
where workflow_id = #{workflowId} and vehicle_id is null
|
|
||||||
</update>
|
|
||||||
|
|
||||||
<select id="selectInboundTaskItems" resultType="com.ruoyi.algorithm.wms.InboundTaskItem">
|
<select id="selectInboundTaskItems" resultType="com.ruoyi.algorithm.wms.InboundTaskItem">
|
||||||
select coalesce(nullif(ltrim(rtrim(isd.spare4)), ''),
|
select coalesce(nullif(ltrim(rtrim(isd.spare4)), ''),
|
||||||
nullif(ltrim(rtrim(bs.spare4)), '')) as projectNo,
|
nullif(ltrim(rtrim(bs.spare4)), '')) as projectNo,
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package com.ruoyi.algorithm.wms.tidy;
|
||||||
|
|
||||||
|
import com.ruoyi.algorithm.integration.AlgorithmProperties;
|
||||||
|
import com.ruoyi.algorithm.integration.SchedulingFacade;
|
||||||
|
import com.ruoyi.algorithm.integration.SchedulingMode;
|
||||||
|
import com.ruoyi.algorithm.tidy.TidyCandidate;
|
||||||
|
import com.ruoyi.algorithm.tidy.TidyCandidateType;
|
||||||
|
import com.ruoyi.algorithm.tidy.TidyMove;
|
||||||
|
import com.ruoyi.algorithm.tidy.TidyPlan;
|
||||||
|
import com.ruoyi.algorithm.wms.WmsAlgorithmMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ManualTidySchedulerTest {
|
||||||
|
@Mock
|
||||||
|
private TidyModeService modeService;
|
||||||
|
@Mock
|
||||||
|
private WcsVehicleSnapshotClient vehicleClient;
|
||||||
|
@Mock
|
||||||
|
private SchedulingFacade facade;
|
||||||
|
@Mock
|
||||||
|
private TidyWorkflowTransaction workflows;
|
||||||
|
@Mock
|
||||||
|
private TidyWorkflowRecoveryService recovery;
|
||||||
|
@Mock
|
||||||
|
private WmsAlgorithmMapper mapper;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startsPlannerCandidatesUpToIdleAndConfiguredCapacity() {
|
||||||
|
ManualTidyScheduler scheduler = scheduler(3);
|
||||||
|
|
||||||
|
TidyCandidate first = candidate("TRAY-1", "A-01", "B-01");
|
||||||
|
TidyCandidate second = candidate("TRAY-2", "A-02", "B-02");
|
||||||
|
enableManualTidyWithTwoIdleVehicles();
|
||||||
|
when(mapper.countActiveManualTidy("01")).thenReturn(1);
|
||||||
|
plan(first, second);
|
||||||
|
|
||||||
|
scheduler.reconcileAndFill();
|
||||||
|
|
||||||
|
ArgumentCaptor<TidyCandidate> candidates = ArgumentCaptor.forClass(TidyCandidate.class);
|
||||||
|
verify(workflows, times(2)).start(eq(TidyOrigin.MANUAL_TIDY), eq("01"), candidates.capture());
|
||||||
|
assertThat(candidates.getAllValues()).containsExactly(first, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void limitsPlannerCandidatesToConfiguredMaximum() {
|
||||||
|
ManualTidyScheduler scheduler = scheduler(1);
|
||||||
|
TidyCandidate first = candidate("TRAY-1", "A-01", "B-01");
|
||||||
|
TidyCandidate second = candidate("TRAY-2", "A-02", "B-02");
|
||||||
|
enableManualTidyWithTwoIdleVehicles();
|
||||||
|
when(mapper.countActiveManualTidy("01")).thenReturn(0);
|
||||||
|
plan(first, second);
|
||||||
|
|
||||||
|
scheduler.reconcileAndFill();
|
||||||
|
|
||||||
|
ArgumentCaptor<TidyCandidate> candidates = ArgumentCaptor.forClass(TidyCandidate.class);
|
||||||
|
verify(workflows).start(eq(TidyOrigin.MANUAL_TIDY), eq("01"), candidates.capture());
|
||||||
|
assertThat(candidates.getValue()).isEqualTo(first);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void propagatesWorkflowFailureSoTheSchedulingTransactionRollsBack() {
|
||||||
|
ManualTidyScheduler scheduler = scheduler(2);
|
||||||
|
TidyCandidate first = candidate("TRAY-1", "A-01", "B-01");
|
||||||
|
TidyCandidate second = candidate("TRAY-2", "A-02", "B-02");
|
||||||
|
enableManualTidyWithTwoIdleVehicles();
|
||||||
|
when(mapper.countActiveManualTidy("01")).thenReturn(0);
|
||||||
|
plan(first, second);
|
||||||
|
when(workflows.start(TidyOrigin.MANUAL_TIDY, "01", first))
|
||||||
|
.thenThrow(new IllegalStateException("insert failed"));
|
||||||
|
|
||||||
|
assertThatThrownBy(scheduler::reconcileAndFill)
|
||||||
|
.isInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessage("insert failed");
|
||||||
|
|
||||||
|
verify(workflows).start(TidyOrigin.MANUAL_TIDY, "01", first);
|
||||||
|
verify(workflows, times(1)).start(eq(TidyOrigin.MANUAL_TIDY), eq("01"), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void holdsTheModeRowLockForTheWholeSchedulingTransaction() throws NoSuchMethodException {
|
||||||
|
Method method = ManualTidyScheduler.class.getMethod("reconcileAndFill");
|
||||||
|
|
||||||
|
var attribute = new AnnotationTransactionAttributeSource()
|
||||||
|
.getTransactionAttribute(method, ManualTidyScheduler.class);
|
||||||
|
|
||||||
|
assertThat(attribute).isNotNull();
|
||||||
|
assertThat(attribute.rollbackOn(new RuntimeException("failure"))).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotPlanWhenNoVehicleIsAvailable() {
|
||||||
|
ManualTidyScheduler scheduler = scheduler(3);
|
||||||
|
when(modeService.reconcile("01")).thenReturn(new TidyModeSnapshot(
|
||||||
|
"01", true, TidyEffectiveState.TIDY_ACTIVE, 0, 0, "", 1, "test", null));
|
||||||
|
when(vehicleClient.fetch()).thenReturn(List.of());
|
||||||
|
when(mapper.countActiveManualTidy("01")).thenReturn(0);
|
||||||
|
|
||||||
|
scheduler.reconcileAndFill();
|
||||||
|
|
||||||
|
verifyNoInteractions(facade, workflows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ManualTidyScheduler scheduler(int maxConcurrency) {
|
||||||
|
AlgorithmProperties properties = new AlgorithmProperties();
|
||||||
|
properties.setMode(SchedulingMode.ACTIVE);
|
||||||
|
properties.setWarehouseId("01");
|
||||||
|
properties.setTidyManualMaxConcurrency(maxConcurrency);
|
||||||
|
return new ManualTidyScheduler(modeService, vehicleClient,
|
||||||
|
facade, workflows, recovery, mapper, properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void enableManualTidyWithTwoIdleVehicles() {
|
||||||
|
when(modeService.reconcile("01")).thenReturn(new TidyModeSnapshot(
|
||||||
|
"01", true, TidyEffectiveState.TIDY_ACTIVE, 0, 1, "", 1, "test", null));
|
||||||
|
when(vehicleClient.fetch()).thenReturn(List.of(
|
||||||
|
new VehicleSnapshot("A", true, true, true, ""),
|
||||||
|
new VehicleSnapshot("B", true, true, true, "")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void plan(TidyCandidate... candidates) {
|
||||||
|
when(facade.planTidy(eq("01"), any(Instant.class)))
|
||||||
|
.thenReturn(new TidyPlan(List.of(candidates), List.of()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TidyCandidate candidate(String tray, String source, String target) {
|
||||||
|
return new TidyCandidate(TidyCandidateType.GUEST_RETURN, "PROJECT-1",
|
||||||
|
List.of(new TidyMove(tray, source, target)), Optional.empty(), "test");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,30 +3,71 @@ package com.ruoyi.algorithm.wms.tidy;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
class TidyVehicleCapacityTest {
|
class TidyVehicleCapacityTest {
|
||||||
@Test
|
@Test
|
||||||
void countsOnlyHealthyOnlineIdleVehiclesAndLeavesRunningWorkInCapacity() {
|
void doesNotSubtractRunningWorkflowFromAlreadyIdleSnapshot() {
|
||||||
VehicleSnapshot healthyIdle = new VehicleSnapshot("1", true, true, true, null);
|
VehicleSnapshot firstIdle = new VehicleSnapshot("1", true, true, true, null);
|
||||||
VehicleSnapshot healthyTidy = new VehicleSnapshot("2", true, true, false, "MANUAL_TIDY");
|
VehicleSnapshot secondIdle = new VehicleSnapshot("2", true, true, true, null);
|
||||||
VehicleSnapshot busyInbound = new VehicleSnapshot("3", true, true, false, "INBOUND");
|
VehicleSnapshot busyTidy = new VehicleSnapshot("3", true, true, false, "MANUAL_TIDY");
|
||||||
|
|
||||||
TidyVehicleCapacity.CapacityResult result = new TidyVehicleCapacity().calculate(
|
TidyVehicleCapacity.CapacityResult result = new TidyVehicleCapacity().calculate(
|
||||||
List.of(healthyIdle, healthyTidy, busyInbound), 1, Set.of("2"));
|
List.of(firstIdle, secondIdle, busyTidy), 1, 3);
|
||||||
|
|
||||||
assertThat(result.effectiveCapacity()).isEqualTo(1);
|
assertThat(result.effectiveCapacity()).isEqualTo(2);
|
||||||
assertThat(result.newSlots()).isEqualTo(0);
|
assertThat(result.newSlots()).isEqualTo(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void offlineOrAlarmedAssignedVehicleCannotConsumeNewCapacity() {
|
void limitsNewSlotsByConfiguredConcurrency() {
|
||||||
|
List<VehicleSnapshot> vehicles = List.of(
|
||||||
|
new VehicleSnapshot("1", true, true, true, null),
|
||||||
|
new VehicleSnapshot("2", true, true, true, null),
|
||||||
|
new VehicleSnapshot("3", true, true, true, null));
|
||||||
|
|
||||||
TidyVehicleCapacity.CapacityResult result = new TidyVehicleCapacity().calculate(
|
TidyVehicleCapacity.CapacityResult result = new TidyVehicleCapacity().calculate(
|
||||||
List.of(new VehicleSnapshot("1", false, false, false, "MANUAL_TIDY"),
|
vehicles, 1, 2);
|
||||||
new VehicleSnapshot("2", true, true, true, null)), 0, Set.of("1"));
|
|
||||||
assertThat(result.effectiveCapacity()).isEqualTo(1);
|
assertThat(result.effectiveCapacity()).isEqualTo(3);
|
||||||
assertThat(result.newSlots()).isEqualTo(1);
|
assertThat(result.newSlots()).isEqualTo(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsNoSlotsWhenConcurrencyLimitIsReached() {
|
||||||
|
List<VehicleSnapshot> vehicles = List.of(
|
||||||
|
new VehicleSnapshot("1", true, true, true, null),
|
||||||
|
new VehicleSnapshot("2", true, true, true, null));
|
||||||
|
|
||||||
|
TidyVehicleCapacity.CapacityResult result = new TidyVehicleCapacity().calculate(
|
||||||
|
vehicles, 2, 2);
|
||||||
|
|
||||||
|
assertThat(result.effectiveCapacity()).isEqualTo(2);
|
||||||
|
assertThat(result.newSlots()).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unavailableVehiclesDoNotProduceSlots() {
|
||||||
|
List<VehicleSnapshot> vehicles = List.of(
|
||||||
|
new VehicleSnapshot("1", false, false, false, null),
|
||||||
|
new VehicleSnapshot("2", true, false, true, null),
|
||||||
|
new VehicleSnapshot("3", true, true, false, "INBOUND"));
|
||||||
|
|
||||||
|
TidyVehicleCapacity.CapacityResult result = new TidyVehicleCapacity().calculate(
|
||||||
|
vehicles, 0, 3);
|
||||||
|
|
||||||
|
assertThat(result.effectiveCapacity()).isZero();
|
||||||
|
assertThat(result.newSlots()).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nonPositiveConcurrencyDisablesNewWorkflows() {
|
||||||
|
List<VehicleSnapshot> vehicles = List.of(
|
||||||
|
new VehicleSnapshot("1", true, true, true, null));
|
||||||
|
|
||||||
|
assertThat(new TidyVehicleCapacity().calculate(vehicles, 0, 0).newSlots()).isZero();
|
||||||
|
assertThat(new TidyVehicleCapacity().calculate(vehicles, 0, -1).newSlots()).isZero();
|
||||||
|
assertThat(new TidyVehicleCapacity().calculate(vehicles, 1, Integer.MIN_VALUE).newSlots()).isZero();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user