fix: keep shadow work off legacy threads
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
package com.ruoyi.algorithm.shadow;
|
||||||
|
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
|
||||||
|
public final class InboundShadowCompletion {
|
||||||
|
private static final InboundShadowCompletion REJECTED =
|
||||||
|
new InboundShadowCompletion(false, 0L);
|
||||||
|
|
||||||
|
private final boolean accepted;
|
||||||
|
private final long deadlineNanos;
|
||||||
|
private final CompletableFuture<LegacyResult> legacyResult = new CompletableFuture<>();
|
||||||
|
|
||||||
|
static InboundShadowCompletion accepted(int timeoutSeconds) {
|
||||||
|
return new InboundShadowCompletion(
|
||||||
|
true, System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static InboundShadowCompletion rejected() {
|
||||||
|
return REJECTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
private InboundShadowCompletion(boolean accepted, long deadlineNanos) {
|
||||||
|
this.accepted = accepted;
|
||||||
|
this.deadlineNanos = deadlineNanos;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean accepted() {
|
||||||
|
return accepted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void complete(String resultCode, String spaceId) {
|
||||||
|
if (accepted) {
|
||||||
|
legacyResult.complete(new LegacyResult(resultCode, spaceId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LegacyResult awaitLegacyResult() throws Exception {
|
||||||
|
long remainingNanos = deadlineNanos - System.nanoTime();
|
||||||
|
if (remainingNanos <= 0) {
|
||||||
|
throw new TimeoutException("Shadow inbound observation deadline expired");
|
||||||
|
}
|
||||||
|
return legacyResult.get(remainingNanos, TimeUnit.NANOSECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
record LegacyResult(String resultCode, String spaceId) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.ruoyi.algorithm.shadow;
|
||||||
|
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
|
||||||
|
public final class OutboundShadowCompletion {
|
||||||
|
private static final OutboundShadowCompletion REJECTED =
|
||||||
|
new OutboundShadowCompletion(false, 0L);
|
||||||
|
|
||||||
|
private final boolean accepted;
|
||||||
|
private final long deadlineNanos;
|
||||||
|
private final CompletableFuture<String> legacyResult = new CompletableFuture<>();
|
||||||
|
|
||||||
|
static OutboundShadowCompletion accepted(int timeoutSeconds) {
|
||||||
|
return new OutboundShadowCompletion(
|
||||||
|
true, System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static OutboundShadowCompletion rejected() {
|
||||||
|
return REJECTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
private OutboundShadowCompletion(boolean accepted, long deadlineNanos) {
|
||||||
|
this.accepted = accepted;
|
||||||
|
this.deadlineNanos = deadlineNanos;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean accepted() {
|
||||||
|
return accepted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void complete(String resultCode) {
|
||||||
|
if (accepted) {
|
||||||
|
legacyResult.complete(resultCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String awaitLegacyResult() throws Exception {
|
||||||
|
long remainingNanos = deadlineNanos - System.nanoTime();
|
||||||
|
if (remainingNanos <= 0) {
|
||||||
|
throw new TimeoutException("Shadow outbound observation deadline expired");
|
||||||
|
}
|
||||||
|
return legacyResult.get(remainingNanos, TimeUnit.NANOSECONDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
package com.ruoyi.algorithm.shadow;
|
package com.ruoyi.algorithm.shadow;
|
||||||
|
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.RejectedExecutionException;
|
||||||
|
import java.util.concurrent.SynchronousQueue;
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class ShadowObservationGateway {
|
public class ShadowObservationGateway {
|
||||||
@@ -13,88 +17,95 @@ public class ShadowObservationGateway {
|
|||||||
|
|
||||||
private final ShadowObservationProperties properties;
|
private final ShadowObservationProperties properties;
|
||||||
private final ShadowPlanningEngine engine;
|
private final ShadowPlanningEngine engine;
|
||||||
private final AtomicBoolean planning = new AtomicBoolean();
|
private final ThreadPoolExecutor executor;
|
||||||
|
|
||||||
public ShadowObservationGateway(ShadowObservationProperties properties, ShadowPlanningEngine engine) {
|
public ShadowObservationGateway(ShadowObservationProperties properties, ShadowPlanningEngine engine) {
|
||||||
this.properties = Objects.requireNonNull(properties, "properties");
|
this.properties = Objects.requireNonNull(properties, "properties");
|
||||||
this.engine = engine;
|
this.engine = engine;
|
||||||
|
this.executor = new ThreadPoolExecutor(
|
||||||
|
1, 1, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>(), runnable -> {
|
||||||
|
Thread thread = new Thread(runnable, "ft546-shadow-observer");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
}, new ThreadPoolExecutor.AbortPolicy());
|
||||||
}
|
}
|
||||||
|
|
||||||
public InboundShadowObservation planInbound(String taskNo, String trayNo) {
|
public InboundShadowCompletion observeInbound(String taskNo, String trayNo) {
|
||||||
if (!properties.isEnabled() || engine == null) {
|
if (!available()) {
|
||||||
return InboundShadowObservation.empty();
|
return InboundShadowCompletion.rejected();
|
||||||
}
|
|
||||||
if (!planning.compareAndSet(false, true)) {
|
|
||||||
return InboundShadowObservation.empty();
|
|
||||||
}
|
}
|
||||||
|
InboundShadowCompletion completion =
|
||||||
|
InboundShadowCompletion.accepted(properties.getTimeoutSeconds());
|
||||||
try {
|
try {
|
||||||
return engine.planInbound(taskNo, trayNo);
|
executor.execute(() -> observeInbound(taskNo, trayNo, completion));
|
||||||
} catch (RuntimeException exception) {
|
return completion;
|
||||||
LOG.warn("Shadow inbound planning failed open for task {}: {}", taskNo, exception.getMessage());
|
} catch (RejectedExecutionException exception) {
|
||||||
return InboundShadowObservation.empty();
|
LOG.debug("Shadow inbound observation skipped because worker is busy for task {}", taskNo);
|
||||||
} finally {
|
return InboundShadowCompletion.rejected();
|
||||||
planning.set(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public OutboundShadowObservation planProjectOutbound(String stockoutDetailId) {
|
public OutboundShadowCompletion observeProjectOutbound(String stockoutDetailId) {
|
||||||
if (!properties.isEnabled() || engine == null) {
|
return observeOutbound(stockoutDetailId,
|
||||||
return OutboundShadowObservation.empty();
|
() -> engine.planProjectOutbound(stockoutDetailId));
|
||||||
}
|
}
|
||||||
if (!planning.compareAndSet(false, true)) {
|
|
||||||
return OutboundShadowObservation.empty();
|
public OutboundShadowCompletion observeExactStockOutbound(String stockoutDetailId, String stockId) {
|
||||||
|
return observeOutbound(stockoutDetailId,
|
||||||
|
() -> engine.planExactStockOutbound(stockoutDetailId, stockId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void observeInbound(String taskNo, String trayNo, InboundShadowCompletion completion) {
|
||||||
try {
|
try {
|
||||||
return engine.planProjectOutbound(stockoutDetailId);
|
InboundShadowObservation observation = engine.planInbound(taskNo, trayNo);
|
||||||
} catch (RuntimeException exception) {
|
InboundShadowCompletion.LegacyResult legacy = completion.awaitLegacyResult();
|
||||||
LOG.warn("Shadow outbound planning failed open for detail {}: {}",
|
engine.compareInbound(observation, legacy.resultCode(), legacy.spaceId());
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
LOG.warn("Shadow inbound observation interrupted for task {}", taskNo);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
LOG.warn("Shadow inbound observation failed open for task {}: {}", taskNo, exception.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private OutboundShadowCompletion observeOutbound(String stockoutDetailId,
|
||||||
|
OutboundPlanner planner) {
|
||||||
|
if (!available()) {
|
||||||
|
return OutboundShadowCompletion.rejected();
|
||||||
|
}
|
||||||
|
OutboundShadowCompletion completion =
|
||||||
|
OutboundShadowCompletion.accepted(properties.getTimeoutSeconds());
|
||||||
|
try {
|
||||||
|
executor.execute(() -> {
|
||||||
|
try {
|
||||||
|
OutboundShadowObservation observation = planner.plan();
|
||||||
|
engine.compareOutbound(observation, completion.awaitLegacyResult());
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
LOG.warn("Shadow outbound observation interrupted for detail {}", stockoutDetailId);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
LOG.warn("Shadow outbound observation failed open for detail {}: {}",
|
||||||
stockoutDetailId, exception.getMessage());
|
stockoutDetailId, exception.getMessage());
|
||||||
return OutboundShadowObservation.empty();
|
}
|
||||||
} finally {
|
});
|
||||||
planning.set(false);
|
return completion;
|
||||||
|
} catch (RejectedExecutionException exception) {
|
||||||
|
LOG.debug("Shadow outbound observation skipped because worker is busy for detail {}", stockoutDetailId);
|
||||||
|
return OutboundShadowCompletion.rejected();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public OutboundShadowObservation planExactStockOutbound(String stockoutDetailId, String stockId) {
|
private boolean available() {
|
||||||
if (!properties.isEnabled() || engine == null) {
|
return properties.isEnabled() && engine != null && !executor.isShutdown();
|
||||||
return OutboundShadowObservation.empty();
|
|
||||||
}
|
|
||||||
if (!planning.compareAndSet(false, true)) {
|
|
||||||
return OutboundShadowObservation.empty();
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return engine.planExactStockOutbound(stockoutDetailId, stockId);
|
|
||||||
} catch (RuntimeException exception) {
|
|
||||||
LOG.warn("Shadow exact-stock planning failed open for detail {} stock {}: {}",
|
|
||||||
stockoutDetailId, stockId, exception.getMessage());
|
|
||||||
return OutboundShadowObservation.empty();
|
|
||||||
} finally {
|
|
||||||
planning.set(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void compareInbound(InboundShadowObservation observation, String legacyResultCode,
|
@PreDestroy
|
||||||
String legacySpaceId) {
|
public void shutdown() {
|
||||||
if (!properties.isEnabled() || engine == null || observation == null || !observation.observed()) {
|
executor.shutdownNow();
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
engine.compareInbound(observation, legacyResultCode, legacySpaceId);
|
|
||||||
} catch (RuntimeException exception) {
|
|
||||||
LOG.warn("Shadow inbound comparison failed open for request {}: {}",
|
|
||||||
observation.requestId(), exception.getMessage());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void compareOutbound(OutboundShadowObservation observation, String legacyResultCode) {
|
@FunctionalInterface
|
||||||
if (!properties.isEnabled() || engine == null || observation == null || !observation.observed()) {
|
private interface OutboundPlanner {
|
||||||
return;
|
OutboundShadowObservation plan();
|
||||||
}
|
|
||||||
try {
|
|
||||||
engine.compareOutbound(observation, legacyResultCode);
|
|
||||||
} catch (RuntimeException exception) {
|
|
||||||
LOG.warn("Shadow outbound comparison failed open for request {}: {}",
|
|
||||||
observation.requestId(), exception.getMessage());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import cn.hutool.core.date.DateUtil;
|
|||||||
import cn.hutool.core.util.ObjUtil;
|
import cn.hutool.core.util.ObjUtil;
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
import cn.hutool.json.JSONUtil;
|
import cn.hutool.json.JSONUtil;
|
||||||
import com.ruoyi.algorithm.shadow.InboundShadowObservation;
|
import com.ruoyi.algorithm.shadow.InboundShadowCompletion;
|
||||||
import com.ruoyi.algorithm.shadow.ShadowObservationGateway;
|
import com.ruoyi.algorithm.shadow.ShadowObservationGateway;
|
||||||
import com.ruoyi.api.domain.*;
|
import com.ruoyi.api.domain.*;
|
||||||
import com.ruoyi.api.service.IToErpService;
|
import com.ruoyi.api.service.IToErpService;
|
||||||
@@ -416,21 +416,21 @@ public class ToWmsServiceImpl implements IToWmsService {
|
|||||||
|
|
||||||
map.put("v_tray_code", inJob.getTrayCode());
|
map.put("v_tray_code", inJob.getTrayCode());
|
||||||
map.put("v_code", deviceCode);
|
map.put("v_code", deviceCode);
|
||||||
InboundShadowObservation shadowObservation = InboundShadowObservation.empty();
|
InboundShadowCompletion shadowCompletion = InboundShadowCompletion.rejected();
|
||||||
try {
|
try {
|
||||||
shadowObservation = shadowObservationGateway.planInbound(taskNo, inJob.getTrayCode());
|
shadowCompletion = shadowObservationGateway.observeInbound(taskNo, inJob.getTrayCode());
|
||||||
} catch (RuntimeException exception) {
|
} catch (RuntimeException exception) {
|
||||||
log.warn("Inbound shadow observation failed open for task {}: {}",
|
log.warn("Inbound shadow submission failed open for task {}: {}",
|
||||||
taskNo, exception.getMessage());
|
taskNo, exception.getMessage());
|
||||||
}
|
}
|
||||||
mydoJobMapper.allotInJobSpace(map);
|
mydoJobMapper.allotInJobSpace(map);
|
||||||
String legacyResultCode = map.get("v_result").toString();
|
String legacyResultCode = map.get("v_result").toString();
|
||||||
if (!"10".equals(legacyResultCode)) {
|
if (!"10".equals(legacyResultCode)) {
|
||||||
compareInboundShadow(shadowObservation, legacyResultCode, null);
|
shadowCompletion.complete(legacyResultCode, null);
|
||||||
return AjaxResult.error(map.get("v_resultmsg").toString());
|
return AjaxResult.error(map.get("v_resultmsg").toString());
|
||||||
}else{
|
}else{
|
||||||
MydoJob inJob2 = mydoJobMapper.selectInJobByTaskNo(taskNo);
|
MydoJob inJob2 = mydoJobMapper.selectInJobByTaskNo(taskNo);
|
||||||
compareInboundShadow(shadowObservation, legacyResultCode,
|
shadowCompletion.complete(legacyResultCode,
|
||||||
inJob2 == null ? null : inJob2.getCargoSpaceId());
|
inJob2 == null ? null : inJob2.getCargoSpaceId());
|
||||||
return AjaxResult.success(inJob2);
|
return AjaxResult.success(inJob2);
|
||||||
}
|
}
|
||||||
@@ -440,15 +440,4 @@ public class ToWmsServiceImpl implements IToWmsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void compareInboundShadow(InboundShadowObservation observation,
|
|
||||||
String legacyResultCode, String legacySpaceId) {
|
|
||||||
try {
|
|
||||||
shadowObservationGateway.compareInbound(observation, legacyResultCode, legacySpaceId);
|
|
||||||
} catch (RuntimeException exception) {
|
|
||||||
log.warn("Inbound shadow comparison failed open for request {}: {}",
|
|
||||||
observation == null ? null : observation.requestId(), exception.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.ruoyi.out.outStockout.service.impl;
|
package com.ruoyi.out.outStockout.service.impl;
|
||||||
|
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
import com.ruoyi.algorithm.shadow.OutboundShadowObservation;
|
import com.ruoyi.algorithm.shadow.OutboundShadowCompletion;
|
||||||
import com.ruoyi.algorithm.shadow.ShadowObservationGateway;
|
import com.ruoyi.algorithm.shadow.ShadowObservationGateway;
|
||||||
import com.ruoyi.api.service.IToWcsService;
|
import com.ruoyi.api.service.IToWcsService;
|
||||||
import com.ruoyi.bas.basJobId.service.IBasJobIdService;
|
import com.ruoyi.bas.basJobId.service.IBasJobIdService;
|
||||||
@@ -188,10 +188,10 @@ public class OutStockoutServiceImpl implements IOutStockoutService
|
|||||||
map.put("v_stockout_detail_id", detail.getStockoutDetailId());
|
map.put("v_stockout_detail_id", detail.getStockoutDetailId());
|
||||||
map.put("v_result", null);
|
map.put("v_result", null);
|
||||||
map.put("v_resultmsg", null);
|
map.put("v_resultmsg", null);
|
||||||
OutboundShadowObservation shadowObservation =
|
OutboundShadowCompletion shadowCompletion =
|
||||||
planProjectOutboundShadow(detail.getStockoutDetailId());
|
observeProjectOutboundShadow(detail.getStockoutDetailId());
|
||||||
outStockoutMapper.autoAllot(map);
|
outStockoutMapper.autoAllot(map);
|
||||||
compareOutboundShadow(shadowObservation, map.get("v_result"));
|
shadowCompletion.complete(map.get("v_result"));
|
||||||
if (!"10".equals(map.get("v_result"))){
|
if (!"10".equals(map.get("v_result"))){
|
||||||
stringBuilder.append(",").append(detail.getStockoutDetailId()+map.get("v_resultmsg"));
|
stringBuilder.append(",").append(detail.getStockoutDetailId()+map.get("v_resultmsg"));
|
||||||
}
|
}
|
||||||
@@ -218,10 +218,10 @@ public class OutStockoutServiceImpl implements IOutStockoutService
|
|||||||
for (String stock : stockList) {
|
for (String stock : stockList) {
|
||||||
map.put("v_stockout_detail_id", stockoutDetailId);
|
map.put("v_stockout_detail_id", stockoutDetailId);
|
||||||
map.put("v_stock_id", stock);
|
map.put("v_stock_id", stock);
|
||||||
OutboundShadowObservation shadowObservation =
|
OutboundShadowCompletion shadowCompletion =
|
||||||
planExactStockOutboundShadow(stockoutDetailId, stock);
|
observeExactStockOutboundShadow(stockoutDetailId, stock);
|
||||||
outStockoutMapper.manualAllot(map);
|
outStockoutMapper.manualAllot(map);
|
||||||
compareOutboundShadow(shadowObservation, map.get("v_result"));
|
shadowCompletion.complete(map.get("v_result"));
|
||||||
//toWcsService.pushOutMydoJob();
|
//toWcsService.pushOutMydoJob();
|
||||||
if (!"10".equals(map.get("v_result"))){
|
if (!"10".equals(map.get("v_result"))){
|
||||||
stringBuilder.append(",").append(stockoutDetailId+map.get("v_resultString"));
|
stringBuilder.append(",").append(stockoutDetailId+map.get("v_resultString"));
|
||||||
@@ -233,32 +233,23 @@ public class OutStockoutServiceImpl implements IOutStockoutService
|
|||||||
return AjaxResult.success();
|
return AjaxResult.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
private OutboundShadowObservation planProjectOutboundShadow(String stockoutDetailId) {
|
private OutboundShadowCompletion observeProjectOutboundShadow(String stockoutDetailId) {
|
||||||
try {
|
try {
|
||||||
return shadowObservationGateway.planProjectOutbound(stockoutDetailId);
|
return shadowObservationGateway.observeProjectOutbound(stockoutDetailId);
|
||||||
} catch (RuntimeException exception) {
|
} catch (RuntimeException exception) {
|
||||||
log.warn("Project outbound shadow observation failed open for detail {}: {}",
|
log.warn("Project outbound shadow submission failed open for detail {}: {}",
|
||||||
stockoutDetailId, exception.getMessage());
|
stockoutDetailId, exception.getMessage());
|
||||||
return OutboundShadowObservation.empty();
|
return OutboundShadowCompletion.rejected();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private OutboundShadowObservation planExactStockOutboundShadow(String stockoutDetailId, String stockId) {
|
private OutboundShadowCompletion observeExactStockOutboundShadow(String stockoutDetailId, String stockId) {
|
||||||
try {
|
try {
|
||||||
return shadowObservationGateway.planExactStockOutbound(stockoutDetailId, stockId);
|
return shadowObservationGateway.observeExactStockOutbound(stockoutDetailId, stockId);
|
||||||
} catch (RuntimeException exception) {
|
} catch (RuntimeException exception) {
|
||||||
log.warn("Exact-stock shadow observation failed open for detail {} stock {}: {}",
|
log.warn("Exact-stock shadow submission failed open for detail {} stock {}: {}",
|
||||||
stockoutDetailId, stockId, exception.getMessage());
|
stockoutDetailId, stockId, exception.getMessage());
|
||||||
return OutboundShadowObservation.empty();
|
return OutboundShadowCompletion.rejected();
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void compareOutboundShadow(OutboundShadowObservation observation, String legacyResultCode) {
|
|
||||||
try {
|
|
||||||
shadowObservationGateway.compareOutbound(observation, legacyResultCode);
|
|
||||||
} catch (RuntimeException exception) {
|
|
||||||
log.warn("Outbound shadow comparison failed open for request {}: {}",
|
|
||||||
observation == null ? null : observation.requestId(), exception.getMessage());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.ruoyi.algorithm.shadow;
|
package com.ruoyi.algorithm.shadow;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
@@ -11,90 +12,105 @@ import java.util.concurrent.TimeUnit;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.timeout;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
class ShadowObservationGatewayTest {
|
class ShadowObservationGatewayTest {
|
||||||
|
private ShadowObservationGateway gateway;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void shutDownGateway() {
|
||||||
|
if (gateway != null) {
|
||||||
|
gateway.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void disabledShadowNeverInvokesThePlanningEngine() {
|
void disabledShadowNeverInvokesThePlanningEngine() {
|
||||||
ShadowObservationProperties properties = new ShadowObservationProperties();
|
ShadowObservationProperties properties = new ShadowObservationProperties();
|
||||||
ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class);
|
ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class);
|
||||||
ShadowObservationGateway gateway = new ShadowObservationGateway(properties, engine);
|
gateway = new ShadowObservationGateway(properties, engine);
|
||||||
|
|
||||||
InboundShadowObservation observation = gateway.planInbound("TASK-1", "TRAY-1");
|
InboundShadowCompletion completion = gateway.observeInbound("TASK-1", "TRAY-1");
|
||||||
|
completion.complete("10", "SPACE-1");
|
||||||
|
|
||||||
assertThat(observation.observed()).isFalse();
|
|
||||||
verify(engine, never()).planInbound("TASK-1", "TRAY-1");
|
verify(engine, never()).planInbound("TASK-1", "TRAY-1");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void planningFailureIsConvertedToAnEmptyObservation() {
|
void planningFailureIsContainedOnTheBackgroundWorker() {
|
||||||
ShadowObservationProperties properties = new ShadowObservationProperties();
|
ShadowObservationProperties properties = enabledProperties();
|
||||||
properties.setEnabled(true);
|
|
||||||
ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class);
|
ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class);
|
||||||
when(engine.planInbound("TASK-2", "TRAY-2"))
|
when(engine.planInbound("TASK-2", "TRAY-2"))
|
||||||
.thenThrow(new IllegalStateException("algorithm objects are unavailable"));
|
.thenThrow(new IllegalStateException("algorithm objects are unavailable"));
|
||||||
when(engine.planProjectOutbound("DETAIL-2"))
|
gateway = new ShadowObservationGateway(properties, engine);
|
||||||
.thenReturn(OutboundShadowObservation.unavailable("W01", "DETAIL-2", "TEST", "completed"));
|
|
||||||
ShadowObservationGateway gateway = new ShadowObservationGateway(properties, engine);
|
|
||||||
|
|
||||||
InboundShadowObservation observation = gateway.planInbound("TASK-2", "TRAY-2");
|
InboundShadowCompletion completion = gateway.observeInbound("TASK-2", "TRAY-2");
|
||||||
OutboundShadowObservation followingObservation = gateway.planProjectOutbound("DETAIL-2");
|
completion.complete("10", "SPACE-2");
|
||||||
|
|
||||||
assertThat(observation.observed()).isFalse();
|
verify(engine, timeout(1000)).planInbound("TASK-2", "TRAY-2");
|
||||||
assertThat(followingObservation.observed()).isTrue();
|
verify(engine, never()).compareInbound(
|
||||||
|
org.mockito.ArgumentMatchers.any(),
|
||||||
|
org.mockito.ArgumentMatchers.any(),
|
||||||
|
org.mockito.ArgumentMatchers.any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void missingEngineFailsOpenForOutboundObservation() {
|
void missingEngineFailsOpenForOutboundObservation() {
|
||||||
ShadowObservationProperties properties = new ShadowObservationProperties();
|
gateway = new ShadowObservationGateway(enabledProperties(), null);
|
||||||
properties.setEnabled(true);
|
|
||||||
ShadowObservationGateway gateway = new ShadowObservationGateway(properties, null);
|
|
||||||
|
|
||||||
OutboundShadowObservation observation = gateway.planExactStockOutbound("DETAIL-1", "STOCK-1");
|
OutboundShadowCompletion completion =
|
||||||
|
gateway.observeExactStockOutbound("DETAIL-1", "STOCK-1");
|
||||||
|
completion.complete("10");
|
||||||
|
|
||||||
assertThat(observation.observed()).isFalse();
|
assertThat(completion.accepted()).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void concurrentPlanningIsSkippedInsteadOfAddingDatabaseLoad() throws Exception {
|
void planningSubmissionNeverWaitsAndConcurrentObservationIsDropped() throws Exception {
|
||||||
ShadowObservationProperties properties = new ShadowObservationProperties();
|
|
||||||
properties.setEnabled(true);
|
|
||||||
ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class);
|
ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class);
|
||||||
CountDownLatch planningStarted = new CountDownLatch(1);
|
CountDownLatch planningStarted = new CountDownLatch(1);
|
||||||
CountDownLatch releasePlanning = new CountDownLatch(1);
|
CountDownLatch releasePlanning = new CountDownLatch(1);
|
||||||
|
InboundShadowObservation observation = new InboundShadowObservation(
|
||||||
|
true, "W01", "TASK-3", "READY", "SPACE-SHADOW", "proposal");
|
||||||
when(engine.planInbound("TASK-3", "TRAY-3")).thenAnswer(invocation -> {
|
when(engine.planInbound("TASK-3", "TRAY-3")).thenAnswer(invocation -> {
|
||||||
planningStarted.countDown();
|
planningStarted.countDown();
|
||||||
if (!releasePlanning.await(5, TimeUnit.SECONDS)) {
|
if (!releasePlanning.await(5, TimeUnit.SECONDS)) {
|
||||||
throw new IllegalStateException("test planning was not released");
|
throw new IllegalStateException("test planning was not released");
|
||||||
}
|
}
|
||||||
return InboundShadowObservation.unavailable("W01", "TASK-3", "TEST", "completed");
|
return observation;
|
||||||
});
|
});
|
||||||
when(engine.planProjectOutbound("DETAIL-3"))
|
gateway = new ShadowObservationGateway(enabledProperties(), engine);
|
||||||
.thenReturn(OutboundShadowObservation.unavailable("W01", "DETAIL-3", "TEST", "completed"));
|
ExecutorService legacyCaller = Executors.newSingleThreadExecutor();
|
||||||
ShadowObservationGateway gateway = new ShadowObservationGateway(properties, engine);
|
|
||||||
ExecutorService executor = Executors.newSingleThreadExecutor();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Future<InboundShadowObservation> first =
|
Future<InboundShadowCompletion> submitted =
|
||||||
executor.submit(() -> gateway.planInbound("TASK-3", "TRAY-3"));
|
legacyCaller.submit(() -> gateway.observeInbound("TASK-3", "TRAY-3"));
|
||||||
assertThat(planningStarted.await(5, TimeUnit.SECONDS)).isTrue();
|
InboundShadowCompletion first = submitted.get(1, TimeUnit.SECONDS);
|
||||||
|
assertThat(first.accepted()).isTrue();
|
||||||
|
assertThat(planningStarted.await(1, TimeUnit.SECONDS)).isTrue();
|
||||||
|
|
||||||
OutboundShadowObservation concurrent = gateway.planProjectOutbound("DETAIL-3");
|
OutboundShadowCompletion concurrent = gateway.observeProjectOutbound("DETAIL-3");
|
||||||
|
|
||||||
assertThat(concurrent.observed()).isFalse();
|
assertThat(concurrent.accepted()).isFalse();
|
||||||
|
concurrent.complete("10");
|
||||||
verify(engine, never()).planProjectOutbound("DETAIL-3");
|
verify(engine, never()).planProjectOutbound("DETAIL-3");
|
||||||
|
|
||||||
|
first.complete("10", "SPACE-LEGACY");
|
||||||
releasePlanning.countDown();
|
releasePlanning.countDown();
|
||||||
assertThat(first.get(5, TimeUnit.SECONDS).observed()).isTrue();
|
|
||||||
|
|
||||||
OutboundShadowObservation afterRelease = gateway.planProjectOutbound("DETAIL-3");
|
verify(engine, timeout(2000)).compareInbound(observation, "10", "SPACE-LEGACY");
|
||||||
|
|
||||||
assertThat(afterRelease.observed()).isTrue();
|
|
||||||
verify(engine).planProjectOutbound("DETAIL-3");
|
|
||||||
} finally {
|
} finally {
|
||||||
releasePlanning.countDown();
|
releasePlanning.countDown();
|
||||||
executor.shutdownNow();
|
legacyCaller.shutdownNow();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ShadowObservationProperties enabledProperties() {
|
||||||
|
ShadowObservationProperties properties = new ShadowObservationProperties();
|
||||||
|
properties.setEnabled(true);
|
||||||
|
properties.setTimeoutSeconds(3);
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.ruoyi.api.service.impl;
|
package com.ruoyi.api.service.impl;
|
||||||
|
|
||||||
import com.ruoyi.algorithm.shadow.InboundShadowObservation;
|
import com.ruoyi.algorithm.shadow.InboundShadowCompletion;
|
||||||
import com.ruoyi.algorithm.shadow.ShadowObservationGateway;
|
import com.ruoyi.algorithm.shadow.ShadowObservationGateway;
|
||||||
import com.ruoyi.api.domain.RequestEnter;
|
import com.ruoyi.api.domain.RequestEnter;
|
||||||
import com.ruoyi.common.core.domain.AjaxResult;
|
import com.ruoyi.common.core.domain.AjaxResult;
|
||||||
@@ -30,6 +30,8 @@ class ToWmsServiceImplShadowTest {
|
|||||||
private MydoJobMapper mydoJobMapper;
|
private MydoJobMapper mydoJobMapper;
|
||||||
@Mock
|
@Mock
|
||||||
private ShadowObservationGateway shadowObservationGateway;
|
private ShadowObservationGateway shadowObservationGateway;
|
||||||
|
@Mock
|
||||||
|
private InboundShadowCompletion shadowCompletion;
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private ToWmsServiceImpl service;
|
private ToWmsServiceImpl service;
|
||||||
|
|
||||||
@@ -55,33 +57,31 @@ class ToWmsServiceImplShadowTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void observesBeforeLegacyAndReturnsTheReloadedLegacyJob() {
|
void submitsObservationBeforeLegacyAndReturnsTheReloadedLegacyJob() {
|
||||||
InboundShadowObservation observation = new InboundShadowObservation(
|
when(shadowObservationGateway.observeInbound("TASK-1", "TRAY-1"))
|
||||||
true, "W01", "JOB-1", "READY", "SPACE-SHADOW", "proposal");
|
.thenReturn(shadowCompletion);
|
||||||
when(shadowObservationGateway.planInbound("TASK-1", "TRAY-1")).thenReturn(observation);
|
|
||||||
|
|
||||||
AjaxResult result = service.requestEnterByTaskNo(request);
|
AjaxResult result = service.requestEnterByTaskNo(request);
|
||||||
|
|
||||||
assertThat(result.get(AjaxResult.DATA_TAG)).isSameAs(legacyResult);
|
assertThat(result.get(AjaxResult.DATA_TAG)).isSameAs(legacyResult);
|
||||||
InOrder order = inOrder(mydoJobMapper, shadowObservationGateway);
|
InOrder order = inOrder(mydoJobMapper, shadowObservationGateway, shadowCompletion);
|
||||||
order.verify(mydoJobMapper).selectInJobByTaskNo("TASK-1");
|
order.verify(mydoJobMapper).selectInJobByTaskNo("TASK-1");
|
||||||
order.verify(shadowObservationGateway).planInbound("TASK-1", "TRAY-1");
|
order.verify(shadowObservationGateway).observeInbound("TASK-1", "TRAY-1");
|
||||||
order.verify(mydoJobMapper).allotInJobSpace(any());
|
order.verify(mydoJobMapper).allotInJobSpace(any());
|
||||||
order.verify(mydoJobMapper).selectInJobByTaskNo("TASK-1");
|
order.verify(mydoJobMapper).selectInJobByTaskNo("TASK-1");
|
||||||
order.verify(shadowObservationGateway)
|
order.verify(shadowCompletion).complete("10", "SPACE-LEGACY");
|
||||||
.compareInbound(observation, "10", "SPACE-LEGACY");
|
|
||||||
verify(mydoJobMapper, times(1)).allotInJobSpace(any());
|
verify(mydoJobMapper, times(1)).allotInJobSpace(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shadowExceptionCannotSkipOrReplaceLegacyAllocation() {
|
void shadowExceptionCannotSkipOrReplaceLegacyAllocation() {
|
||||||
when(shadowObservationGateway.planInbound("TASK-1", "TRAY-1"))
|
when(shadowObservationGateway.observeInbound("TASK-1", "TRAY-1"))
|
||||||
.thenThrow(new IllegalStateException("shadow unavailable"));
|
.thenThrow(new IllegalStateException("shadow unavailable"));
|
||||||
|
|
||||||
AjaxResult result = service.requestEnterByTaskNo(request);
|
AjaxResult result = service.requestEnterByTaskNo(request);
|
||||||
|
|
||||||
assertThat(result.get(AjaxResult.DATA_TAG)).isSameAs(legacyResult);
|
assertThat(result.get(AjaxResult.DATA_TAG)).isSameAs(legacyResult);
|
||||||
verify(shadowObservationGateway).planInbound("TASK-1", "TRAY-1");
|
verify(shadowObservationGateway).observeInbound("TASK-1", "TRAY-1");
|
||||||
verify(mydoJobMapper, times(1)).allotInJobSpace(any());
|
verify(mydoJobMapper, times(1)).allotInJobSpace(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.ruoyi.out.outStockout.service.impl;
|
package com.ruoyi.out.outStockout.service.impl;
|
||||||
|
|
||||||
import com.ruoyi.algorithm.shadow.OutboundShadowObservation;
|
import com.ruoyi.algorithm.shadow.OutboundShadowCompletion;
|
||||||
import com.ruoyi.algorithm.shadow.ShadowObservationGateway;
|
import com.ruoyi.algorithm.shadow.ShadowObservationGateway;
|
||||||
import com.ruoyi.common.core.domain.AjaxResult;
|
import com.ruoyi.common.core.domain.AjaxResult;
|
||||||
import com.ruoyi.out.outStockout.domain.OutStockout;
|
import com.ruoyi.out.outStockout.domain.OutStockout;
|
||||||
@@ -23,6 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.doAnswer;
|
import static org.mockito.Mockito.doAnswer;
|
||||||
import static org.mockito.Mockito.inOrder;
|
import static org.mockito.Mockito.inOrder;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
@@ -35,6 +36,8 @@ class OutStockoutServiceImplShadowTest {
|
|||||||
private IOutStockoutDetailService outStockoutDetailService;
|
private IOutStockoutDetailService outStockoutDetailService;
|
||||||
@Mock
|
@Mock
|
||||||
private ShadowObservationGateway shadowObservationGateway;
|
private ShadowObservationGateway shadowObservationGateway;
|
||||||
|
@Mock
|
||||||
|
private OutboundShadowCompletion shadowCompletion;
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private OutStockoutServiceImpl service;
|
private OutStockoutServiceImpl service;
|
||||||
|
|
||||||
@@ -51,9 +54,8 @@ class OutStockoutServiceImplShadowTest {
|
|||||||
@Test
|
@Test
|
||||||
void missingShadowProjectStillRunsAutoAllotAndPreservesLegacyWarning() {
|
void missingShadowProjectStillRunsAutoAllotAndPreservesLegacyWarning() {
|
||||||
prepareAutoAllot();
|
prepareAutoAllot();
|
||||||
OutboundShadowObservation missingProject = OutboundShadowObservation.unavailable(
|
when(shadowObservationGateway.observeProjectOutbound("DETAIL-1"))
|
||||||
"W01", "DETAIL-1", "SKIPPED_MISSING_PROJECT", "missing project");
|
.thenReturn(shadowCompletion);
|
||||||
when(shadowObservationGateway.planProjectOutbound("DETAIL-1")).thenReturn(missingProject);
|
|
||||||
doAnswer(invocation -> {
|
doAnswer(invocation -> {
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
HashMap<String, String> parameters = invocation.getArgument(0);
|
HashMap<String, String> parameters = invocation.getArgument(0);
|
||||||
@@ -65,17 +67,17 @@ class OutStockoutServiceImplShadowTest {
|
|||||||
AjaxResult result = service.autoAllot("OUT-1");
|
AjaxResult result = service.autoAllot("OUT-1");
|
||||||
|
|
||||||
assertThat(result.get(AjaxResult.MSG_TAG)).isEqualTo(",DETAIL-1LEGACY-WARNING");
|
assertThat(result.get(AjaxResult.MSG_TAG)).isEqualTo(",DETAIL-1LEGACY-WARNING");
|
||||||
InOrder order = inOrder(shadowObservationGateway, outStockoutMapper);
|
InOrder order = inOrder(shadowObservationGateway, outStockoutMapper, shadowCompletion);
|
||||||
order.verify(shadowObservationGateway).planProjectOutbound("DETAIL-1");
|
order.verify(shadowObservationGateway).observeProjectOutbound("DETAIL-1");
|
||||||
order.verify(outStockoutMapper).autoAllot(any());
|
order.verify(outStockoutMapper).autoAllot(any());
|
||||||
order.verify(shadowObservationGateway).compareOutbound(missingProject, "20");
|
order.verify(shadowCompletion).complete("20");
|
||||||
verify(outStockoutMapper, times(1)).autoAllot(any());
|
verify(outStockoutMapper, times(1)).autoAllot(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shadowExceptionStillRunsAutoAllotAndPreservesLegacySuccess() {
|
void shadowExceptionStillRunsAutoAllotAndPreservesLegacySuccess() {
|
||||||
prepareAutoAllot();
|
prepareAutoAllot();
|
||||||
when(shadowObservationGateway.planProjectOutbound("DETAIL-1"))
|
when(shadowObservationGateway.observeProjectOutbound("DETAIL-1"))
|
||||||
.thenThrow(new IllegalStateException("shadow unavailable"));
|
.thenThrow(new IllegalStateException("shadow unavailable"));
|
||||||
doAnswer(invocation -> {
|
doAnswer(invocation -> {
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@@ -88,18 +90,18 @@ class OutStockoutServiceImplShadowTest {
|
|||||||
AjaxResult result = service.autoAllot("OUT-1");
|
AjaxResult result = service.autoAllot("OUT-1");
|
||||||
|
|
||||||
assertThat(result.isSuccess()).isTrue();
|
assertThat(result.isSuccess()).isTrue();
|
||||||
verify(shadowObservationGateway).planProjectOutbound("DETAIL-1");
|
verify(shadowObservationGateway).observeProjectOutbound("DETAIL-1");
|
||||||
verify(outStockoutMapper, times(1)).autoAllot(any());
|
verify(outStockoutMapper, times(1)).autoAllot(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void missingShadowStockStillRunsManualAllotOncePerSelectedStock() {
|
void missingShadowStockStillRunsManualAllotOncePerSelectedStock() {
|
||||||
OutboundShadowObservation missingStock = OutboundShadowObservation.unavailable(
|
OutboundShadowCompletion firstCompletion = mock(OutboundShadowCompletion.class);
|
||||||
"W01", "DETAIL-1", "SKIPPED_MISSING_STOCK", "missing stock");
|
OutboundShadowCompletion secondCompletion = mock(OutboundShadowCompletion.class);
|
||||||
when(shadowObservationGateway.planExactStockOutbound("DETAIL-1", "STOCK-1"))
|
when(shadowObservationGateway.observeExactStockOutbound("DETAIL-1", "STOCK-1"))
|
||||||
.thenReturn(missingStock);
|
.thenReturn(firstCompletion);
|
||||||
when(shadowObservationGateway.planExactStockOutbound("DETAIL-1", "STOCK-2"))
|
when(shadowObservationGateway.observeExactStockOutbound("DETAIL-1", "STOCK-2"))
|
||||||
.thenReturn(missingStock);
|
.thenReturn(secondCompletion);
|
||||||
doAnswer(invocation -> {
|
doAnswer(invocation -> {
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
HashMap<String, String> parameters = invocation.getArgument(0);
|
HashMap<String, String> parameters = invocation.getArgument(0);
|
||||||
@@ -112,9 +114,10 @@ class OutStockoutServiceImplShadowTest {
|
|||||||
|
|
||||||
assertThat(result.isSuccess()).isTrue();
|
assertThat(result.isSuccess()).isTrue();
|
||||||
verify(outStockoutMapper, times(2)).manualAllot(any());
|
verify(outStockoutMapper, times(2)).manualAllot(any());
|
||||||
verify(shadowObservationGateway).planExactStockOutbound("DETAIL-1", "STOCK-1");
|
verify(shadowObservationGateway).observeExactStockOutbound("DETAIL-1", "STOCK-1");
|
||||||
verify(shadowObservationGateway).planExactStockOutbound("DETAIL-1", "STOCK-2");
|
verify(shadowObservationGateway).observeExactStockOutbound("DETAIL-1", "STOCK-2");
|
||||||
verify(shadowObservationGateway, times(2)).compareOutbound(missingStock, "10");
|
verify(firstCompletion).complete("10");
|
||||||
|
verify(secondCompletion).complete("10");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void prepareAutoAllot() {
|
private void prepareAutoAllot() {
|
||||||
|
|||||||
Reference in New Issue
Block a user