fix(shadow): align outbound predicates with site data for v3.3
Use the business project and captured site predicates, validate single-stock proposals, and skip unsupported scopes without changing Legacy. Add the guarded V3.3 SQL upgrade, CMD operations, regression tests and deployment runbook.
This commit is contained in:
@@ -13,6 +13,7 @@ import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
@@ -29,6 +30,7 @@ public class ShadowPlanningEngine {
|
||||
private static final int RESULT_REASON_MAX_LENGTH = 600;
|
||||
private static final int NUMBER_MAX_LENGTH = 64;
|
||||
private static final String PUBLISHED = "PUBLISHED";
|
||||
private static final int MAX_OUTBOUND_CANDIDATES = 200;
|
||||
|
||||
private final SchedulingFacade schedulingFacade;
|
||||
private final ShadowInputMapper inputMapper;
|
||||
@@ -87,19 +89,83 @@ public class ShadowPlanningEngine {
|
||||
String warehouseId = snapshot.warehouseId();
|
||||
ShadowStockReference reference = inputMapper.selectProjectOutboundReference(
|
||||
warehouseId, snapshot.generation(), stockoutDetailId);
|
||||
if (reference == null || isBlank(reference.getProjectNo())) {
|
||||
return OutboundShadowObservation.unavailable(snapshot, stockoutDetailId,
|
||||
"SKIPPED_MISSING_PROJECT",
|
||||
outboundDiagnostic(snapshot, stockoutDetailId, reference,
|
||||
"No project number was available for shadow planning"));
|
||||
if (reference == null || !Integer.valueOf(1).equals(reference.getPredicateVersion())) {
|
||||
return unavailableProject(snapshot, stockoutDetailId, reference,
|
||||
"SKIPPED_MISSING_PREDICATE_INPUT", "Request is missing or predates predicate version 1");
|
||||
}
|
||||
if (reference.getRequestedQuantity() == null || reference.getRequestedQuantity().signum() <= 0) {
|
||||
return unavailableProject(snapshot, stockoutDetailId, reference,
|
||||
"SKIPPED_INVALID_REMAINING_QUANTITY", "No positive unallocated quantity was captured");
|
||||
}
|
||||
List<ShadowStockReference> candidates = inputMapper.selectProjectOutboundCandidates(
|
||||
warehouseId, snapshot.generation(), stockoutDetailId);
|
||||
if (candidates.size() > MAX_OUTBOUND_CANDIDATES) {
|
||||
return unavailableProject(snapshot, stockoutDetailId, reference,
|
||||
"SKIPPED_CANDIDATE_LIMIT", "Eligible candidate set exceeds 200 rows");
|
||||
}
|
||||
if (candidates.isEmpty()) {
|
||||
return unavailableProject(snapshot, stockoutDetailId, reference,
|
||||
"ERROR_STOCK_NOT_FOUND", "No inventory satisfies the captured outbound predicates");
|
||||
}
|
||||
var projects = candidates.stream().map(ShadowStockReference::getProjectNo).distinct().toList();
|
||||
if (projects.size() != 1 || isBlank(projects.get(0))) {
|
||||
return unavailableProject(snapshot, stockoutDetailId, reference,
|
||||
"SKIPPED_UNSUPPORTED_PROJECT_SCOPE", "Planner requires one nonblank candidate project");
|
||||
}
|
||||
var products = candidates.stream().map(ShadowStockReference::getProductId).distinct().toList();
|
||||
if (products.size() != 1 || isBlank(products.get(0))) {
|
||||
return unavailableProject(snapshot, stockoutDetailId, reference,
|
||||
"SKIPPED_UNSUPPORTED_PRODUCT_SCOPE", "Planner requires one nonblank candidate product");
|
||||
}
|
||||
// The shared planner is single-source and does not apply the site's optional predicates.
|
||||
// Keep it unchanged; validate its proposal against the generation-qualified candidate set.
|
||||
OutboundRequest request = new OutboundRequest(stockoutDetailId, null,
|
||||
reference.getProjectNo(), reference.getProductId(), null, null, null, true);
|
||||
return toOutboundObservation(snapshot, reference.getRequestId(), reference,
|
||||
schedulingFacade.planOutboundFromPublishedSnapshot(warehouseId, request));
|
||||
projects.get(0), products.get(0), null, null, null, false);
|
||||
OutboundPlan plan = schedulingFacade.planOutboundFromPublishedSnapshot(warehouseId, request);
|
||||
if (plan.assignment().isEmpty()) {
|
||||
return toOutboundObservation(snapshot, stockoutDetailId, reference, plan);
|
||||
}
|
||||
var assignment = plan.assignment().orElseThrow();
|
||||
var selected = candidates.stream().filter(candidate ->
|
||||
Objects.equals(candidate.getSpaceId(), assignment.sourceSpaceId())
|
||||
&& Objects.equals(candidate.getTrayNo(), assignment.trayNo())
|
||||
&& Objects.equals(candidate.getProjectNo(), assignment.projectNo())
|
||||
&& Objects.equals(candidate.getProductId(), assignment.productId())).toList();
|
||||
if (selected.size() != 1) {
|
||||
return unavailableProject(snapshot, stockoutDetailId, reference,
|
||||
"SKIPPED_INELIGIBLE_PROPOSAL", "Proposed source is not one unambiguous eligible stock row");
|
||||
}
|
||||
ShadowStockReference stock = selected.get(0);
|
||||
if (!Integer.valueOf(0).equals(stock.getTrayJobCount())) {
|
||||
return unavailableProject(snapshot, stockoutDetailId, reference,
|
||||
"SKIPPED_EXISTING_TRAY_JOB", "Existing tray jobs require unsupported delivery compatibility checks");
|
||||
}
|
||||
if (!Integer.valueOf(1).equals(stock.getTrayItemCount())) {
|
||||
return unavailableProject(snapshot, stockoutDetailId, reference,
|
||||
"SKIPPED_MULTI_STOCK_TRAY", "Selected tray does not contain exactly one inventory row");
|
||||
}
|
||||
if (stock.getAvailableQuantity() == null
|
||||
|| stock.getAvailableQuantity().compareTo(reference.getRequestedQuantity()) < 0) {
|
||||
return unavailableProject(snapshot, stockoutDetailId, reference,
|
||||
"SKIPPED_MULTI_STOCK_REQUEST", "Selected single stock cannot cover the remaining request quantity");
|
||||
}
|
||||
StringBuilder reason = new StringBuilder(outboundDiagnostic(snapshot, stockoutDetailId, reference,
|
||||
assignment.reason()));
|
||||
appendField(reason, "selectedProject", stock.getProjectNo(), WIDE_IDENTIFIER_MAX_LENGTH);
|
||||
appendField(reason, "selectedTray", stock.getTrayNo(), WIDE_IDENTIFIER_MAX_LENGTH);
|
||||
appendField(reason, "availableQuantity", objectOrEmpty(stock.getAvailableQuantity()), NUMBER_MAX_LENGTH);
|
||||
return new OutboundShadowObservation(true, snapshot.generation(), warehouseId,
|
||||
snapshot.synchronizedAt(), snapshot.sourceSignature(), stockoutDetailId,
|
||||
"READY", stock.getStockId(), stock.getSpaceId(), reason.toString());
|
||||
});
|
||||
}
|
||||
|
||||
private OutboundShadowObservation unavailableProject(PublishedShadowSnapshot snapshot, String requestId,
|
||||
ShadowStockReference reference, String code, String reason) {
|
||||
return OutboundShadowObservation.unavailable(snapshot, requestId, code,
|
||||
outboundDiagnostic(snapshot, requestId, reference, reason));
|
||||
}
|
||||
|
||||
public OutboundShadowObservation planExactStockOutbound(PublishedShadowSnapshot snapshot,
|
||||
String stockoutDetailId, String stockId) {
|
||||
return planWithStableSnapshot(snapshot, () -> {
|
||||
@@ -313,6 +379,15 @@ public class ShadowPlanningEngine {
|
||||
reference == null ? null : reference.getSnapshotSynchronizedAt(), SNAPSHOT_SYNC_MAX_LENGTH);
|
||||
appendField(diagnostic, "shadowMatches", numberOrUnknown(
|
||||
reference == null ? null : reference.getShadowMatchCount()), NUMBER_MAX_LENGTH);
|
||||
appendField(diagnostic, "predicateVersion", numberOrUnknown(
|
||||
reference == null ? null : reference.getPredicateVersion()), NUMBER_MAX_LENGTH);
|
||||
if (reference != null && reference.getPredicateVersion() != null) {
|
||||
appendField(diagnostic, "spare1", reference.getSpare1(), 120);
|
||||
appendField(diagnostic, "spare2", reference.getSpare2(), 120);
|
||||
appendField(diagnostic, "spare3", reference.getSpare3(), 120);
|
||||
appendField(diagnostic, "voltage", reference.getVoltage(), 120);
|
||||
appendField(diagnostic, "phase", reference.getPhase(), 120);
|
||||
}
|
||||
appendField(diagnostic, "reason", resultReason, RESULT_REASON_MAX_LENGTH);
|
||||
return diagnostic.toString();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,35 @@ public class ShadowStockReference {
|
||||
private String snapshotSynchronizedAt;
|
||||
private BigDecimal requestedQuantity;
|
||||
private Integer shadowMatchCount;
|
||||
private Integer predicateVersion;
|
||||
private String spare1;
|
||||
private String spare2;
|
||||
private String spare3;
|
||||
private String voltage;
|
||||
private String phase;
|
||||
private BigDecimal availableQuantity;
|
||||
private Integer trayItemCount;
|
||||
private Integer trayJobCount;
|
||||
|
||||
public Integer getTrayJobCount() { return trayJobCount; }
|
||||
public void setTrayJobCount(Integer value) { trayJobCount = value; }
|
||||
|
||||
public Integer getPredicateVersion() { return predicateVersion; }
|
||||
public void setPredicateVersion(Integer value) { predicateVersion = value; }
|
||||
public String getSpare1() { return spare1; }
|
||||
public void setSpare1(String value) { spare1 = value; }
|
||||
public String getSpare2() { return spare2; }
|
||||
public void setSpare2(String value) { spare2 = value; }
|
||||
public String getSpare3() { return spare3; }
|
||||
public void setSpare3(String value) { spare3 = value; }
|
||||
public String getVoltage() { return voltage; }
|
||||
public void setVoltage(String value) { voltage = value; }
|
||||
public String getPhase() { return phase; }
|
||||
public void setPhase(String value) { phase = value; }
|
||||
public BigDecimal getAvailableQuantity() { return availableQuantity; }
|
||||
public void setAvailableQuantity(BigDecimal value) { availableQuantity = value; }
|
||||
public Integer getTrayItemCount() { return trayItemCount; }
|
||||
public void setTrayItemCount(Integer value) { trayItemCount = value; }
|
||||
|
||||
public String getRequestId() {
|
||||
return requestId;
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ShadowInputMapper {
|
||||
@@ -21,6 +22,9 @@ public interface ShadowInputMapper {
|
||||
ShadowStockReference selectStockReference(@Param("warehouseId") String warehouseId,
|
||||
@Param("generation") long generation, @Param("stockId") String stockId);
|
||||
|
||||
List<ShadowStockReference> selectProjectOutboundCandidates(@Param("warehouseId") String warehouseId,
|
||||
@Param("generation") long generation, @Param("stockoutDetailId") String stockoutDetailId);
|
||||
|
||||
boolean claimInstance(@Param("warehouseId") String warehouseId,
|
||||
@Param("instanceId") String instanceId, @Param("staleSeconds") int staleSeconds);
|
||||
|
||||
|
||||
@@ -25,6 +25,29 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
and input.tray_no = #{trayNo}
|
||||
</select>
|
||||
|
||||
<sql id="outboundCandidatePredicate">
|
||||
item.warehouse_id = #{warehouseId}
|
||||
and item.snapshot_generation = #{generation}
|
||||
and cargo.warehouse_id = item.warehouse_id
|
||||
and cargo.snapshot_generation = item.snapshot_generation
|
||||
and cargo.space_id = item.space_id and cargo.tray_code = item.tray_code
|
||||
and cargo.locked = 'N'
|
||||
and ((cargo.space_status = '2' and item.out_quantity = 0)
|
||||
or (cargo.space_status = '4' and item.quantity > item.out_quantity))
|
||||
and (nullif(input.project_no, '') is null or item.project_no = input.project_no)
|
||||
and (nullif(input.product_id, '') is null or item.product_no = input.product_id)
|
||||
and (nullif(input.spare1, '') is null or item.spare1 = input.spare1)
|
||||
and (nullif(input.spare2, '') is null or item.spare2 = input.spare2)
|
||||
and (nullif(input.spare3, '') is null or item.spare3 = input.spare3)
|
||||
and (nullif(input.voltage, '') is null or item.voltage = input.voltage)
|
||||
and (nullif(input.phase, '') is null or item.phase = input.phase)
|
||||
and not exists (
|
||||
select 1 from alg.active_job_snapshot job
|
||||
where job.warehouse_id = item.warehouse_id
|
||||
and job.snapshot_generation = item.snapshot_generation
|
||||
and job.tray_code = item.tray_code and job.in_or_out = '26')
|
||||
</sql>
|
||||
|
||||
<select id="selectProjectOutboundReference"
|
||||
resultType="com.ruoyi.algorithm.shadow.ShadowStockReference">
|
||||
select top (1)
|
||||
@@ -32,21 +55,42 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
input.raw_warehouse_id as warehouseId,
|
||||
input.project_no as projectNo,
|
||||
input.product_id as productId,
|
||||
input.requested_quantity as requestedQuantity,
|
||||
input.requested_quantity - input.allocated_quantity as requestedQuantity,
|
||||
input.predicate_version as predicateVersion,
|
||||
input.spare1, input.spare2, input.spare3, input.voltage, input.phase,
|
||||
convert(varchar(40), input.synchronized_at, 126) as snapshotSynchronizedAt,
|
||||
(select count(*)
|
||||
from alg.inventory_item_snapshot item
|
||||
where item.warehouse_id = #{warehouseId}
|
||||
and item.snapshot_generation = #{generation}
|
||||
and item.project_no = nullif(ltrim(rtrim(input.project_no)), '')
|
||||
and (nullif(ltrim(rtrim(input.product_id)), '') is null
|
||||
or item.product_no = nullif(ltrim(rtrim(input.product_id)), ''))
|
||||
and isnull(item.quantity, 0) > isnull(item.out_quantity, 0))
|
||||
join alg.cargo_space_snapshot cargo on cargo.space_id = item.space_id
|
||||
where <include refid="outboundCandidatePredicate"/>)
|
||||
as shadowMatchCount
|
||||
from alg.shadow_outbound_request_snapshot input
|
||||
where input.warehouse_id = #{warehouseId}
|
||||
and input.snapshot_generation = #{generation}
|
||||
and input.stockout_detail_id = #{stockoutDetailId}
|
||||
and input.predicate_version = 1
|
||||
</select>
|
||||
|
||||
<select id="selectProjectOutboundCandidates" resultType="com.ruoyi.algorithm.shadow.ShadowStockReference">
|
||||
select top (201)
|
||||
item.stock_id as stockId, item.tray_code as trayNo, item.space_id as spaceId,
|
||||
item.project_no as projectNo, item.product_no as productId,
|
||||
item.tray_job_count as trayJobCount,
|
||||
item.quantity - item.out_quantity as availableQuantity,
|
||||
(select count(*) from alg.inventory_item_snapshot trayItem
|
||||
where trayItem.warehouse_id = item.warehouse_id
|
||||
and trayItem.snapshot_generation = item.snapshot_generation
|
||||
and trayItem.space_id = item.space_id and trayItem.tray_code = item.tray_code)
|
||||
as trayItemCount
|
||||
from alg.shadow_outbound_request_snapshot input
|
||||
join alg.inventory_item_snapshot item on item.warehouse_id = input.warehouse_id
|
||||
join alg.cargo_space_snapshot cargo on cargo.space_id = item.space_id
|
||||
where input.warehouse_id = #{warehouseId}
|
||||
and input.snapshot_generation = #{generation}
|
||||
and input.stockout_detail_id = #{stockoutDetailId}
|
||||
and input.predicate_version = 1
|
||||
and <include refid="outboundCandidatePredicate"/>
|
||||
order by item.stock_id
|
||||
</select>
|
||||
|
||||
<select id="selectStockReference" resultType="com.ruoyi.algorithm.shadow.ShadowStockReference">
|
||||
|
||||
Reference in New Issue
Block a user