fix: improve shadow diagnostic matching

This commit is contained in:
zgw
2026-08-28 11:55:43 +08:00
parent da5a0a1561
commit 3f7ed3718d
9 changed files with 218 additions and 20 deletions

View File

@@ -2,6 +2,7 @@ package com.ruoyi.algorithm.shadow;
public class ShadowInboundReference {
private String requestId;
private String warehouseId;
private String trayNo;
private String projectNo;
private String productId;
@@ -15,6 +16,14 @@ public class ShadowInboundReference {
this.requestId = requestId;
}
public String getWarehouseId() {
return warehouseId;
}
public void setWarehouseId(String warehouseId) {
this.warehouseId = warehouseId;
}
public String getTrayNo() {
return trayNo;
}

View File

@@ -41,28 +41,30 @@ public class ShadowPlanningEngine {
public OutboundShadowObservation planProjectOutbound(String stockoutDetailId) {
return transactions.execute(status -> {
ShadowStockReference reference = inputMapper.selectProjectOutboundReference(stockoutDetailId);
String warehouseId = warehouseId();
String warehouseId = warehouseId(reference == null ? null : reference.getWarehouseId());
if (reference == null || isBlank(reference.getProjectNo())) {
return OutboundShadowObservation.unavailable(warehouseId, stockoutDetailId,
"SKIPPED_MISSING_PROJECT", "No project number was available for shadow planning");
}
OutboundRequest request = new OutboundRequest(stockoutDetailId, null,
reference.getProjectNo(), reference.getProductId(), null, null, null, true);
return toOutboundObservation(warehouseId, reference, schedulingFacade.planOutbound(warehouseId, request));
return toOutboundObservation(warehouseId, reference.getRequestId(), reference,
schedulingFacade.planOutbound(warehouseId, request));
});
}
public OutboundShadowObservation planExactStockOutbound(String stockoutDetailId, String stockId) {
return transactions.execute(status -> {
ShadowStockReference reference = inputMapper.selectStockReference(stockId);
String warehouseId = warehouseId();
String warehouseId = warehouseId(reference == null ? null : reference.getWarehouseId());
if (reference == null || isBlank(reference.getTrayNo())) {
return OutboundShadowObservation.unavailable(warehouseId, stockoutDetailId,
"SKIPPED_MISSING_STOCK", "Selected stock was unavailable to shadow planning");
}
OutboundRequest request = new OutboundRequest(stockoutDetailId, reference.getTrayNo(),
reference.getProjectNo(), reference.getProductId(), null, null, null, true);
return toOutboundObservation(warehouseId, reference, schedulingFacade.planOutbound(warehouseId, request));
return toOutboundObservation(warehouseId, stockoutDetailId, reference,
schedulingFacade.planOutbound(warehouseId, request));
});
}
@@ -73,6 +75,7 @@ public class ShadowPlanningEngine {
comparisonCode(observation.proposedSpaceId(), legacySpaceId),
"request=" + observation.requestId() + "; shadow=" + observation.resultCode()
+ "; proposedSpace=" + nullSafe(observation.proposedSpaceId())
+ "; shadowReason=" + nullSafe(observation.reason())
+ "; legacy=" + nullSafe(legacyResultCode)
+ "; legacySpace=" + nullSafe(legacySpaceId)));
}
@@ -84,12 +87,13 @@ public class ShadowPlanningEngine {
"request=" + observation.requestId() + "; shadow=" + observation.resultCode()
+ "; proposedStock=" + nullSafe(observation.proposedStockId())
+ "; proposedSpace=" + nullSafe(observation.proposedSpaceId())
+ "; shadowReason=" + nullSafe(observation.reason())
+ "; legacy=" + nullSafe(legacyResultCode)));
}
private InboundShadowObservation planInboundInsideTransaction(String taskNo, String trayNo) {
String warehouseId = warehouseId();
ShadowInboundReference reference = inputMapper.selectInboundInput(taskNo, trayNo);
String warehouseId = warehouseId(reference == null ? null : reference.getWarehouseId());
if (reference == null || isBlank(reference.getRequestId()) || isBlank(reference.getTrayNo())) {
return InboundShadowObservation.unavailable(warehouseId, taskNo,
"SKIPPED_MISSING_INPUT", "Inbound task details were unavailable to shadow planning");
@@ -102,38 +106,90 @@ public class ShadowPlanningEngine {
if (plan.assignment().isPresent()) {
var assignment = plan.assignment().orElseThrow();
return new InboundShadowObservation(true, warehouseId, reference.getRequestId(),
"READY", assignment.spaceId(), assignment.reason());
"READY", assignment.spaceId(), inboundDiagnostic(taskNo, trayNo, reference, assignment.reason()));
}
var rejection = plan.rejection().orElseThrow();
return InboundShadowObservation.unavailable(warehouseId, reference.getRequestId(),
"REJECTED_" + rejection.code().name(), rejection.reason());
"REJECTED_" + rejection.code().name(),
inboundDiagnostic(taskNo, trayNo, reference, rejection.reason()));
}
private OutboundShadowObservation toOutboundObservation(String warehouseId,
private OutboundShadowObservation toOutboundObservation(String warehouseId, String requestId,
ShadowStockReference reference, OutboundPlan plan) {
String requestId = reference.getRequestId();
if (isBlank(requestId)) {
requestId = plan.assignment().map(assignment -> assignment.requestId()).orElse(null);
}
if (plan.assignment().isPresent()) {
var assignment = plan.assignment().orElseThrow();
return new OutboundShadowObservation(true, warehouseId, requestId, "READY",
reference.getStockId(), assignment.sourceSpaceId(), assignment.reason());
reference.getStockId(), assignment.sourceSpaceId(),
outboundDiagnostic(requestId, reference, assignment.reason()));
}
if (plan.waiting().isPresent()) {
var waiting = plan.waiting().orElseThrow();
return OutboundShadowObservation.unavailable(warehouseId, requestId,
"WAITING_" + waiting.code().name(), waiting.reason());
"WAITING_" + waiting.code().name(), outboundDiagnostic(requestId, reference, waiting.reason()));
}
var error = plan.error().orElseThrow();
return OutboundShadowObservation.unavailable(warehouseId, requestId,
"ERROR_" + error.code().name(), error.message());
"ERROR_" + error.code().name(), outboundDiagnostic(requestId, reference, error.message()));
}
private String warehouseId() {
return isBlank(properties.getWarehouseId())
private String warehouseId(String preferredWarehouseId) {
String configuredWarehouseId = isBlank(properties.getWarehouseId())
? schedulingFacade.defaultWarehouseId()
: properties.getWarehouseId();
if (!isBlank(preferredWarehouseId)) {
String normalizedPreferred = preferredWarehouseId.trim();
if (sameNumericWarehouse(normalizedPreferred, configuredWarehouseId)) {
return configuredWarehouseId;
}
return normalizedPreferred;
}
return configuredWarehouseId;
}
private boolean sameNumericWarehouse(String first, String second) {
if (isBlank(first) || isBlank(second)) {
return false;
}
try {
return Integer.parseInt(first) == Integer.parseInt(second);
} catch (NumberFormatException ignored) {
return false;
}
}
private String outboundDiagnostic(String requestId, ShadowStockReference reference, String resultReason) {
return "detail=" + nullSafe(requestId)
+ "; project=" + nullSafe(reference.getProjectNo())
+ "; product=" + nullSafe(reference.getProductId())
+ "; tray=" + nullSafe(reference.getTrayNo())
+ "; stock=" + nullSafe(reference.getStockId())
+ "; snapshotSync=" + nullSafe(reference.getSnapshotSynchronizedAt())
+ "; legacyMatches=" + numberOrUnknown(reference.getLiveMatchCount())
+ "; shadowMatches=" + numberOrUnknown(reference.getShadowMatchCount())
+ "; reason=" + abbreviate(nullSafe(resultReason), 600);
}
private String inboundDiagnostic(String taskNo, String trayNo, ShadowInboundReference reference,
String resultReason) {
return "task=" + nullSafe(taskNo)
+ "; tray=" + nullSafe(trayNo)
+ "; request=" + nullSafe(reference.getRequestId())
+ "; warehouse=" + nullSafe(reference.getWarehouseId())
+ "; project=" + nullSafe(reference.getProjectNo())
+ "; product=" + nullSafe(reference.getProductId())
+ "; lot=" + nullSafe(reference.getLotNo())
+ "; reason=" + abbreviate(nullSafe(resultReason), 600);
}
private String numberOrUnknown(Integer value) {
return value == null ? "" : value.toString();
}
private String abbreviate(String value, int maxLength) {
return value.length() <= maxLength ? value : value.substring(0, maxLength);
}
private String comparisonCode(String shadowSpaceId, String legacySpaceId) {

View File

@@ -8,6 +8,9 @@ public class ShadowStockReference {
private String warehouseId;
private String projectNo;
private String productId;
private String snapshotSynchronizedAt;
private Integer liveMatchCount;
private Integer shadowMatchCount;
public String getRequestId() {
return requestId;
@@ -64,4 +67,28 @@ public class ShadowStockReference {
public void setProductId(String productId) {
this.productId = productId;
}
public String getSnapshotSynchronizedAt() {
return snapshotSynchronizedAt;
}
public void setSnapshotSynchronizedAt(String snapshotSynchronizedAt) {
this.snapshotSynchronizedAt = snapshotSynchronizedAt;
}
public Integer getLiveMatchCount() {
return liveMatchCount;
}
public void setLiveMatchCount(Integer liveMatchCount) {
this.liveMatchCount = liveMatchCount;
}
public Integer getShadowMatchCount() {
return shadowMatchCount;
}
public void setShadowMatchCount(Integer shadowMatchCount) {
this.shadowMatchCount = shadowMatchCount;
}
}

View File

@@ -7,6 +7,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectInboundInput" resultType="com.ruoyi.algorithm.shadow.ShadowInboundReference">
select top (1)
job.job_id as requestId,
job.warehouse_id as warehouseId,
job.tray_code as trayNo,
coalesce(nullif(ltrim(rtrim(detail.spare4)), ''),
nullif(ltrim(rtrim(stock.spare4)), '')) as projectNo,
@@ -25,7 +26,26 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
select detail.stockout_detail_id as requestId,
detail.warehouse_id as warehouseId,
nullif(ltrim(rtrim(detail.spare4)), '') as projectNo,
detail.product_id as productId
detail.product_id as productId,
convert(varchar(40), (select max(snapshot.synchronized_at)
from alg.inventory_item_snapshot snapshot
where snapshot.warehouse_id = detail.warehouse_id), 126)
as snapshotSynchronizedAt,
(select count(*)
from bas_stock live
where live.warehouse_id = detail.warehouse_id
and live.stock_status = '1'
and live.product_status = '1'
and live.quantity &gt; live.out_quantity
and (NULLIF(LTRIM(RTRIM(detail.product_id)), '') IS NULL
OR live.product_id = NULLIF(LTRIM(RTRIM(detail.product_id)), ''))) as liveMatchCount,
(select count(*)
from alg.inventory_item_snapshot snapshot
where snapshot.warehouse_id = detail.warehouse_id
and snapshot.project_no = nullif(ltrim(rtrim(detail.spare4)), '')
and (NULLIF(LTRIM(RTRIM(detail.product_id)), '') IS NULL
OR snapshot.product_no = NULLIF(LTRIM(RTRIM(detail.product_id)), '')))
as shadowMatchCount
from out_stockout_detail detail
where detail.stockout_detail_id = #{stockoutDetailId}
</select>
@@ -37,7 +57,21 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
stock.cargo_space_id as spaceId,
stock.warehouse_id as warehouseId,
nullif(ltrim(rtrim(stock.spare4)), '') as projectNo,
stock.product_id as productId
stock.product_id as productId,
convert(varchar(40), (select max(snapshot.synchronized_at)
from alg.inventory_item_snapshot snapshot
where snapshot.warehouse_id = stock.warehouse_id), 126)
as snapshotSynchronizedAt,
(select count(*)
from bas_stock live
where live.stock_id = stock.stock_id
and live.stock_status = '1'
and live.product_status = '1'
and live.quantity &gt; live.out_quantity) as liveMatchCount,
(select count(*)
from alg.inventory_item_snapshot snapshot
where snapshot.stock_id = stock.stock_id
and snapshot.warehouse_id = stock.warehouse_id) as shadowMatchCount
from bas_stock stock
where stock.stock_id = #{stockId}
and stock.stock_status = '1'

View File

@@ -5,6 +5,9 @@ import com.ruoyi.algorithm.inbound.InboundBorrowType;
import com.ruoyi.algorithm.inbound.InboundLadderRung;
import com.ruoyi.algorithm.inbound.InboundPlan;
import com.ruoyi.algorithm.integration.SchedulingFacade;
import com.ruoyi.algorithm.outbound.OutboundAssignment;
import com.ruoyi.algorithm.outbound.OutboundPlan;
import com.ruoyi.algorithm.outbound.FifoDecision;
import com.ruoyi.algorithm.repository.VersionToken;
import com.ruoyi.algorithm.service.InboundPlanningResult;
import com.ruoyi.algorithm.shadow.mapper.ShadowInputMapper;
@@ -16,8 +19,10 @@ import org.springframework.transaction.support.SimpleTransactionStatus;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ShadowPlanningEngineTest {
@@ -25,13 +30,14 @@ class ShadowPlanningEngineTest {
@Test
void inboundPlanningUsesRequiresNewAndConfiguredTimeout() {
ShadowObservationProperties properties = new ShadowObservationProperties();
properties.setWarehouseId("W01");
properties.setWarehouseId("FALLBACK");
properties.setTimeoutSeconds(7);
CapturingTransactionManager transactions = new CapturingTransactionManager();
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
SchedulingFacade facade = mock(SchedulingFacade.class);
ShadowInboundReference input = new ShadowInboundReference();
input.setRequestId("JOB-1");
input.setWarehouseId("W01");
input.setTrayNo("TRAY-1");
input.setProjectNo("PROJECT-1");
input.setProductId("PRODUCT-1");
@@ -56,6 +62,69 @@ class ShadowPlanningEngineTest {
assertThat(observation.proposedSpaceId()).isEqualTo("SPACE-9");
}
@Test
void outboundPlanningUsesReferenceWarehouseAndKeepsMatchDiagnostics() {
ShadowObservationProperties properties = new ShadowObservationProperties();
properties.setWarehouseId("FALLBACK");
CapturingTransactionManager transactions = new CapturingTransactionManager();
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
SchedulingFacade facade = mock(SchedulingFacade.class);
ShadowStockReference reference = new ShadowStockReference();
reference.setRequestId("DETAIL-1");
reference.setWarehouseId("W02");
reference.setProjectNo("PROJECT-1");
reference.setProductId("PRODUCT-1");
reference.setSnapshotSynchronizedAt("2026-08-28T02:53:45.133Z");
reference.setLiveMatchCount(2);
reference.setShadowMatchCount(1);
when(mapper.selectProjectOutboundReference("DETAIL-1")).thenReturn(reference);
OutboundAssignment assignment = new OutboundAssignment(
"DETAIL-1", "TRAY-1", "PROJECT-1", "PRODUCT-1", "SPACE-9", 1,
true, java.util.List.of(), java.util.List.of(), false,
1.0, 2.0, 3.0, FifoDecision.off(), java.util.Optional.empty(),
"shadow proposal");
when(facade.planOutbound(eq("W02"), any()))
.thenReturn(OutboundPlan.ready(assignment, com.ruoyi.algorithm.outbound.TaskCountEstimate.zero()));
ShadowPlanningEngine engine = new ShadowPlanningEngine(facade, mapper, properties, transactions);
OutboundShadowObservation observation = engine.planProjectOutbound("DETAIL-1");
engine.compareOutbound(observation, "10");
assertThat(observation.warehouseId()).isEqualTo("W02");
assertThat(observation.requestId()).isEqualTo("DETAIL-1");
assertThat(observation.proposedSpaceId()).isEqualTo("SPACE-9");
verify(mapper).insertComparison(any(), eq("W02"), eq("OUTBOUND_COMPARE"), eq("LEGACY_10"),
argThat(reason -> reason.contains("detail=DETAIL-1")
&& reason.contains("project=PROJECT-1")
&& reason.contains("product=PRODUCT-1")
&& reason.contains("snapshotSync=2026-08-28T02:53:45.133Z")
&& reason.contains("legacyMatches=2")
&& reason.contains("shadowMatches=1")));
}
@Test
void outboundPlanningTreatsPaddedAndUnpaddedWarehouseIdsAsEquivalent() {
ShadowObservationProperties properties = new ShadowObservationProperties();
properties.setWarehouseId("01");
CapturingTransactionManager transactions = new CapturingTransactionManager();
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
SchedulingFacade facade = mock(SchedulingFacade.class);
ShadowStockReference reference = new ShadowStockReference();
reference.setRequestId("DETAIL-2");
reference.setWarehouseId("1");
reference.setProjectNo("PROJECT-2");
when(mapper.selectProjectOutboundReference("DETAIL-2")).thenReturn(reference);
when(facade.planOutbound(eq("01"), any())).thenReturn(
OutboundPlan.error(new com.ruoyi.algorithm.outbound.OutboundError(
com.ruoyi.algorithm.outbound.OutboundErrorCode.STOCK_NOT_FOUND,
"no stock", java.util.Map.of())));
ShadowPlanningEngine engine = new ShadowPlanningEngine(facade, mapper, properties, transactions);
OutboundShadowObservation observation = engine.planProjectOutbound("DETAIL-2");
assertThat(observation.warehouseId()).isEqualTo("01");
}
private static final class CapturingTransactionManager implements PlatformTransactionManager {
private TransactionDefinition definition;

View File

@@ -123,6 +123,7 @@ class OutStockoutServiceImplShadowTest {
private void prepareAutoAllot() {
OutStockout bill = new OutStockout();
bill.setStatus("2");
bill.setWarehouseId("1");
when(outStockoutMapper.selectOutStockoutByStockoutId("OUT-1")).thenReturn(bill);
when(outStockoutDetailService.selectOutStockoutDetailList(any()))
.thenReturn(List.of(pendingDetail));