fix: repair shadow candidate planning and site runtime errors

This commit is contained in:
zgw
2026-09-09 09:57:28 +08:00
parent 1a34d72cbb
commit 0bd47e2b2c
27 changed files with 788 additions and 203 deletions

View File

@@ -0,0 +1,31 @@
# 2026-09-09 影子修复包切换
本包仍是 V3.4 数据库配套的 SHADOW 程序。无需执行数据库升级或卸载。Legacy 继续负责正式入出库。
## 现场操作
1. 在 IDEA 的运行配置列表确认能看到 `FT546WMS-V34-REPAIR-0909`。先不要启动。
2. 在协调好的时间停止当前 WMS:`FT546WMS-V34-HALFTRAY-FIX`。不要停止 WCS 或 WCS 影子程序。
3. 确认旧 WMS 完全退出,8080 不再监听。CMD 可执行 `netstat -ano | findstr :8080`;旧 PID 没有 LISTENING 才继续。
4. 选择 `FT546WMS-V34-REPAIR-0909`,点运行。等日志出现 `Started RuoYiApplication`,检查登录和查询。不要同时启动两个 WMS。
5. 记录启动时间,在正常业务发生后复核新的影子比较和电子屏发送情况。无需为测试制造业务单据。
新 JAR 目录:`C:\project\FT546WMS-v34-repair-20260909`。
继续使用服务器原有外置配置 `C:\project\FT546WMS-v34-jar-20260908\config`,不修改密码、数据库连接或影子开关。新增启动项不执行 Maven,不覆盖运行中的 JAR。
## 回退
如果新程序启动失败或影响操作,停止新 WMS,确认进程退出后启动旧配置 `FT546WMS-V34-HALFTRAY-FIX`。数据库没有变更,不需要回退 SQL。若需要完全关闭影子,使用已留存的 `FT546WMS-ROLLBACK-SOURCE-NO-BUILD`,先核对其 `FORTUCKY_SHADOW_ENABLED=false`。
## 本次修复与观察边界
- 出库从同代快照的候选库存行保留项目/物料,支持一个足量库存行位于多明细托盘。保留真实阻挡、锁定、预留、拓扑及数量校验。需要多个库存行凑量、候选跨多个项目/物料、已有托盘作业等仍明确标记不支持。
- 普通项目多物料入库采用项目级 OPEN 输入,不把任意一条明细当成全部。提前筛选 Legacy 保守可用货位后排名。
- 同一任务 spare1/2/3 有多组值时,只评估整条空巷道;记录 `READY_CONSERVATIVE` 与 `MATCH_CONSERVATIVE` / `DIFFERENT_CONSERVATIVE`。它们不是完整 Legacy 等价验证,验收时不能混入普通 MATCH。
- 出库原因新增候选证据、集合 SHA-256、完整性标记。`candidateEvidenceComplete=false` 表示原因字段空间不足,只保留部分明细,摘要不能代替完整历史回放。
- 非法 `stockinId[limit]` 请求返回参数格式提示且不查询明细;合法接口和服务端异常处理保留原行为。
- 电子屏两个定时任务独立持有消息和 socket;争用原 6666 端口时下一周期再发送,失败清理资源,保留 5 次协议重试。
- SQL 51025 仍表示源数据在刷新中变化而拒绝发布;不屏蔽该一致性保护,不增加影子刷新超时。
这次修复不等于批准新算法接管正式业务。两个历史出库代次已被覆盖,尚不能完整回放;请以本包启动后的新样本继续评估。至少按既有观察要求收集同版本有效出入库样本,并单独复核保守比较、跳过、差异和性能。

View File

@@ -0,0 +1,12 @@
# 现场修复实施计划
设计:../specs/2026-09-09-shadow-repair-design.md
- [x] 现场证据和根因调查;干净隔离工作树基线测试通过。
- [x] 添加查询绑定、UDP、入出库候选投影回归测试,确认失败。
- [x] 实现无数据库升级的修复;逐组测试。
- [x] 完整离线构建和全部 JUnit 测试,独立审查;审查修正后的最终构建成功。
- [ ] 生成版本化部署包并上传,新增启动项;不重启。
- [ ] 交付切换步骤、回退方式和仍需现场验证的边界。
后续上传与现场配置结果以独立交付目录中的 build-verification.json / site-preparation.json 为准,避免预先声明部署完成。

View File

@@ -0,0 +1,25 @@
# 2026-09-09 现场问题修复
用户已授权修复现场检查发现的问题;继续 Legacy 执行业务、SHADOW 观察,准备交付但不重启现场。
## 证据与边界
现场 29 条记录中,5 条入库都不可比;3 次 Legacy 成功出库有 2 次影子缺货、1 次多明细跳过。历史库存代次已覆盖,修复机制需要回归测试和新样本,不宣称已重放两条历史作业。候选 SQL 的 include 已限制仓库及代次,不存在探查报告提到的跨仓跨代缺口。
## 设计
1. 出库增加仅在 SHADOW 可用的候选库存规划入口。传递快照筛选出的 stockId,以库存行投影项目/物料;保留巷道全部物理货位、阻挡、锁定、预留及缓冲条件。单行足量、多明细托盘可参与;多行合并数量、已有托盘作业仍明确不支持。限定最多 200 个候选。记录有界候选明细和完整集合摘要,不把 Legacy=10 当作匹配通过。
2. 入库在排名前限定同代 Legacy 保守可用货位。普通项目 OPEN 模式下,多物料/批次按项目级任务规划,不虚构单个物料。字段组唯一时保留严格同组或空巷道;字段组不唯一时只允许空巷道,单列保守比较结果,不作为完整 Legacy 等价验证。保留公共项目、范围不全、作业歧义等保护。
3. 查询参数绑定错误返回明确的参数格式提示,不执行无筛选明细查询。
4. 电子屏每次发送拥有独立消息和 socket。固定本地 6666 端口用非阻塞所有权保护,冲突时保留未发送状态待下个周期。try-with-resources 释放;socket 构造失败传播,接收信息为实例字段。保留屏幕协议、内容与重试次数。
5. SQL 51025 是源数据在刷新期间变化的拒绝保护;继续保留,不放松一致性或提高生产超时。本次不改数据库结构或生产过程。
## 验证与交付
先执行失败回归,再实现修复。覆盖真实 Spring 参数绑定、UDP 本机回环隔离/失败释放、混合托盘及阻挡、同代候选、入库分组/空巷道与保守比较。完整离线构建并显式 Surefire 执行 JUnit 5。独立代码审查后,新目录上传净化 JAR,新增 IDEA 启动项;核对摘要及现有进程未变化。用户择机停止旧 WMS 并切换,保留旧启动项回退。
## 核验和审查处置
2026-09-09 只读取得现场 `dbo.proc_allot_space`,普通项目两个分支均 UNION 整条空巷道,且均要求 `locked='N'`。不按共享模型对 0/null 的宽松解释放宽现场 SQL 谓词。完整定义保留本机 analysis/site-review-20260909/inbound-procedure.json。
独立审查要求保留瞬时 UDP 发送/接收错误的重试,已在协议循环内恢复有界重试并补回归;InvalidPropertyException 仅在实际 ModelAttribute 参数绑定且非法数组参数名来自请求时返回400,内部异常维持500,补真实 MockMvc 回归。候选跨多个项目/物料和多行凑量保留明确 skip;支持的是匹配单个项目/物料的库存行处于多项目/多物料托盘的情况,不声称覆盖任意多项目订单。

View File

@@ -472,6 +472,7 @@ public final class InboundPlanner {
} }
private boolean usableSlot(Lane lane, SpaceState space, InboundRequest request, InboundContext context) { private boolean usableSlot(Lane lane, SpaceState space, InboundRequest request, InboundContext context) {
if (request.eligibleSpaceIds() != null && !request.eligibleSpaceIds().contains(space.spaceId())) return false;
if (!lane.usableForInbound() || !space.availableEmpty() || space.role() != SpaceRole.STORAGE) return false; if (!lane.usableForInbound() || !space.availableEmpty() || space.role() != SpaceRole.STORAGE) return false;
if (request.targetFloor() != null && space.floorNo() != request.targetFloor()) return false; if (request.targetFloor() != null && space.floorNo() != request.targetFloor()) return false;
if (request.targetFloor() == null && !request.allowedFloors().isEmpty() if (request.targetFloor() == null && !request.allowedFloors().isEmpty()

View File

@@ -12,8 +12,16 @@ public record InboundRequest(
Integer entryFloor, Integer entryFloor,
Integer targetFloor, Integer targetFloor,
Set<Integer> allowedFloors, Set<Integer> allowedFloors,
BatchLaneMode batchLaneMode BatchLaneMode batchLaneMode,
Set<String> eligibleSpaceIds
) { ) {
public InboundRequest(String requestId, String trayNo, String projectNo, String productId, String lotNo,
Integer expectedProjectPallets, Integer entryFloor, Integer targetFloor,
Set<Integer> allowedFloors, BatchLaneMode batchLaneMode) {
this(requestId, trayNo, projectNo, productId, lotNo, expectedProjectPallets, entryFloor, targetFloor,
allowedFloors, batchLaneMode, null);
}
public InboundRequest { public InboundRequest {
if (requestId == null || requestId.isBlank()) throw new IllegalArgumentException("requestId is required"); if (requestId == null || requestId.isBlank()) throw new IllegalArgumentException("requestId is required");
if (trayNo == null || trayNo.isBlank()) throw new IllegalArgumentException("trayNo is required"); if (trayNo == null || trayNo.isBlank()) throw new IllegalArgumentException("trayNo is required");
@@ -22,5 +30,7 @@ public record InboundRequest(
} }
allowedFloors = allowedFloors == null ? Set.of() : Set.copyOf(allowedFloors); allowedFloors = allowedFloors == null ? Set.of() : Set.copyOf(allowedFloors);
batchLaneMode = batchLaneMode == null ? BatchLaneMode.OPEN : batchLaneMode; batchLaneMode = batchLaneMode == null ? BatchLaneMode.OPEN : batchLaneMode;
// null means unrestricted; an empty captured set means no eligible destination.
eligibleSpaceIds = eligibleSpaceIds == null ? null : Set.copyOf(eligibleSpaceIds);
} }
} }

View File

@@ -60,6 +60,14 @@ public final class SchedulingFacade {
return applicationService.planOutboundFromPublishedSnapshot(warehouseId, request); return applicationService.planOutboundFromPublishedSnapshot(warehouseId, request);
} }
public OutboundPlan planShadowOutboundCandidates(String warehouseId, OutboundRequest request,
java.util.Set<String> eligibleStockIds) {
if (properties.getMode() != SchedulingMode.SHADOW) {
throw new IllegalStateException("Captured candidate planning requires SHADOW mode");
}
return applicationService.planShadowOutboundCandidates(warehouseId, request, eligibleStockIds);
}
public TidyPlan planTidy(String warehouseId, Instant now) { public TidyPlan planTidy(String warehouseId, Instant now) {
requireAlgorithmMode(); requireAlgorithmMode();
return applicationService.planTidy(warehouseId, now); return applicationService.planTidy(warehouseId, now);

View File

@@ -18,7 +18,14 @@ import java.util.Optional;
public final class OutboundPlanner { public final class OutboundPlanner {
public OutboundPlan plan(OutboundRequest request, OutboundContext context) { public OutboundPlan plan(OutboundRequest request, OutboundContext context) {
return plan(request, context, null);
}
public OutboundPlan plan(OutboundRequest request, OutboundContext context, java.util.Set<String> eligibleSpaceIds) {
String mode = normalizedMode(request.fifoMode()); String mode = normalizedMode(request.fifoMode());
if (eligibleSpaceIds != null && !"OFF".equals(mode)) {
throw new IllegalArgumentException("Captured candidate planning supports FIFO OFF only");
}
if (!List.of("OFF", "STRICT", "FLEXIBLE").contains(mode)) { if (!List.of("OFF", "STRICT", "FLEXIBLE").contains(mode)) {
return error(OutboundErrorCode.FIFO_INVALID_MODE, "Unsupported FIFO mode", return error(OutboundErrorCode.FIFO_INVALID_MODE, "Unsupported FIFO mode",
Map.of("fifoMode", String.valueOf(request.fifoMode()))); Map.of("fifoMode", String.valueOf(request.fifoMode())));
@@ -35,6 +42,7 @@ public final class OutboundPlanner {
List<SpaceState> physicalMatches = context.spaces().stream() List<SpaceState> physicalMatches = context.spaces().stream()
.filter(SpaceState::occupied) .filter(SpaceState::occupied)
.filter(space -> eligibleSpaceIds == null || eligibleSpaceIds.contains(space.spaceId()))
.filter(space -> matchesRequest(space, request)) .filter(space -> matchesRequest(space, request))
.toList(); .toList();
if (physicalMatches.isEmpty()) { if (physicalMatches.isEmpty()) {

View File

@@ -359,6 +359,17 @@ public final class JdbcWarehouseRepository implements WarehouseRepository {
@Override @Override
public List<LaneSnapshot> loadLaneSnapshots(String warehouseId, Collection<String> laneCodes) { public List<LaneSnapshot> loadLaneSnapshots(String warehouseId, Collection<String> laneCodes) {
return loadLaneSnapshots(warehouseId, laneCodes, Set.of());
}
@Override
public List<LaneSnapshot> loadShadowOutboundLaneSnapshots(String warehouseId, Collection<String> laneCodes,
Set<String> candidateSpaceIds) {
return loadLaneSnapshots(warehouseId, laneCodes, Set.copyOf(candidateSpaceIds));
}
private List<LaneSnapshot> loadLaneSnapshots(String warehouseId, Collection<String> laneCodes,
Set<String> mixedProjectSources) {
LinkedHashSet<String> codes = laneCodes == null ? new LinkedHashSet<>() LinkedHashSet<String> codes = laneCodes == null ? new LinkedHashSet<>()
: laneCodes.stream().filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); : laneCodes.stream().filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
if (codes.isEmpty()) return List.of(); if (codes.isEmpty()) return List.of();
@@ -393,7 +404,7 @@ public final class JdbcWarehouseRepository implements WarehouseRepository {
int capacity = entry.getValue().size(); int capacity = entry.getValue().size();
List<SpaceSnapshot> spaces = entry.getValue().stream() List<SpaceSnapshot> spaces = entry.getValue().stream()
.map(raw -> mapSpace(raw, capacity, versions.get(entry.getKey()), .map(raw -> mapSpace(raw, capacity, versions.get(entry.getKey()),
inventoryBySpace.getOrDefault(raw.spaceId(), List.of()))) inventoryBySpace.getOrDefault(raw.spaceId(), List.of()), mixedProjectSources.contains(raw.spaceId())))
.sorted(Comparator.comparingInt((SpaceSnapshot value) -> value.state().depthOrder()) .sorted(Comparator.comparingInt((SpaceSnapshot value) -> value.state().depthOrder())
.thenComparing(SpaceSnapshot::spaceId)).toList(); .thenComparing(SpaceSnapshot::spaceId)).toList();
result.add(new LaneSnapshot(warehouseId, entry.getKey(), versions.get(entry.getKey()), spaces)); result.add(new LaneSnapshot(warehouseId, entry.getKey(), versions.get(entry.getKey()), spaces));
@@ -720,6 +731,11 @@ public final class JdbcWarehouseRepository implements WarehouseRepository {
} }
private SpaceSnapshot mapSpace(RawSpace raw, int capacity, VersionToken version, List<InventoryItem> inventory) { private SpaceSnapshot mapSpace(RawSpace raw, int capacity, VersionToken version, List<InventoryItem> inventory) {
return mapSpace(raw, capacity, version, inventory, false);
}
private SpaceSnapshot mapSpace(RawSpace raw, int capacity, VersionToken version,
List<InventoryItem> inventory, boolean allowMixedProjectSource) {
EnumSet<SpaceConflict> conflicts = EnumSet.noneOf(SpaceConflict.class); EnumSet<SpaceConflict> conflicts = EnumSet.noneOf(SpaceConflict.class);
if (raw.trayConflict()) conflicts.add(SpaceConflict.TRAY); if (raw.trayConflict()) conflicts.add(SpaceConflict.TRAY);
if (raw.projectConflict()) conflicts.add(SpaceConflict.PROJECT); if (raw.projectConflict()) conflicts.add(SpaceConflict.PROJECT);
@@ -734,7 +750,8 @@ public final class JdbcWarehouseRepository implements WarehouseRepository {
Instant inboundAt = inventory.stream().map(InventoryItem::inboundAt).filter(Objects::nonNull) Instant inboundAt = inventory.stream().map(InventoryItem::inboundAt).filter(Objects::nonNull)
.min(Comparator.naturalOrder()).orElse(null); .min(Comparator.naturalOrder()).orElse(null);
boolean invalid = locked(raw.locked()) || raw.reserved() || raw.hasGap() || raw.depth() == null boolean invalid = locked(raw.locked()) || raw.reserved() || raw.hasGap() || raw.depth() == null
|| !conflicts.isEmpty() || ("1".equals(raw.spaceStatus()) && (!inventory.isEmpty() || tray != null)); || conflicts.stream().anyMatch(conflict -> conflict != SpaceConflict.PROJECT || !allowMixedProjectSource)
|| ("1".equals(raw.spaceStatus()) && (!inventory.isEmpty() || tray != null));
SpaceStatus status = invalid ? SpaceStatus.DISABLED : switch (Objects.toString(raw.spaceStatus(), "")) { SpaceStatus status = invalid ? SpaceStatus.DISABLED : switch (Objects.toString(raw.spaceStatus(), "")) {
case "1" -> SpaceStatus.EMPTY; case "1" -> SpaceStatus.EMPTY;
case "2" -> SpaceStatus.OCCUPIED; case "2" -> SpaceStatus.OCCUPIED;

View File

@@ -16,6 +16,10 @@ public interface WarehouseRepository {
LanePage pageCandidateLanes(String warehouseId, String afterLaneCode, int pageSize); LanePage pageCandidateLanes(String warehouseId, String afterLaneCode, int pageSize);
LanePage pageWarehouseLanes(String warehouseId, String afterLaneCode, int pageSize); LanePage pageWarehouseLanes(String warehouseId, String afterLaneCode, int pageSize);
List<LaneSnapshot> loadLaneSnapshots(String warehouseId, Collection<String> laneCodes); List<LaneSnapshot> loadLaneSnapshots(String warehouseId, Collection<String> laneCodes);
default List<LaneSnapshot> loadShadowOutboundLaneSnapshots(String warehouseId, Collection<String> laneCodes,
Set<String> candidateSpaceIds) {
return loadLaneSnapshots(warehouseId, laneCodes);
}
List<SpaceSnapshot> loadRelocationBuffers(String warehouseId, Set<Integer> floors); List<SpaceSnapshot> loadRelocationBuffers(String warehouseId, Set<Integer> floors);
Set<Integer> loadActiveDeepWorkflowFloors(String warehouseId, String excludingRequestId); Set<Integer> loadActiveDeepWorkflowFloors(String warehouseId, String excludingRequestId);
boolean acquireDeepWorkflowLock(String requestId, String warehouseId, int floorNo); boolean acquireDeepWorkflowLock(String requestId, String warehouseId, int floorNo);

View File

@@ -98,7 +98,8 @@ public final class WarehouseApplicationService {
int currentProjectPallets = repository.countProjectPallets(warehouseId, request.projectNo()); int currentProjectPallets = repository.countProjectPallets(warehouseId, request.projectNo());
effectiveRequest = new InboundRequest(request.requestId(), request.trayNo(), request.projectNo(), effectiveRequest = new InboundRequest(request.requestId(), request.trayNo(), request.projectNo(),
request.productId(), request.lotNo(), currentProjectPallets <= 1 ? 2 : 4, request.productId(), request.lotNo(), currentProjectPallets <= 1 ? 2 : 4,
request.entryFloor(), request.targetFloor(), request.allowedFloors(), request.batchLaneMode()); request.entryFloor(), request.targetFloor(), request.allowedFloors(), request.batchLaneMode(),
request.eligibleSpaceIds());
} }
List<PendingOutbound> pending = repository.loadPendingOutboundJobs(warehouseId).stream() List<PendingOutbound> pending = repository.loadPendingOutboundJobs(warehouseId).stream()
.map(job -> new PendingOutbound(job.projectNo(), job.productNo(), job.trayNo(), .map(job -> new PendingOutbound(job.projectNo(), job.productNo(), job.trayNo(),
@@ -136,6 +137,18 @@ public final class WarehouseApplicationService {
return planOutbound(warehouseId, request, false); return planOutbound(warehouseId, request, false);
} }
/** The caller holds a published snapshot lease and supplies the site's eligible inventory IDs. */
public OutboundPlan planShadowOutboundCandidates(String warehouseId, OutboundRequest request,
Set<String> eligibleStockIds) {
Objects.requireNonNull(eligibleStockIds, "eligibleStockIds");
if (eligibleStockIds.size() > 200 || request.wholeTrayOrDefault() || nonBlank(request.trayNo())
|| !nonBlank(request.projectNo()) || !nonBlank(request.productId())
|| (nonBlank(request.fifoMode()) && !"OFF".equalsIgnoreCase(request.fifoMode()))) {
throw new IllegalArgumentException("Shadow candidates require a bounded project/product picking scope with FIFO OFF");
}
return planOutboundFromCurrentReadModel(warehouseId, request, Set.copyOf(eligibleStockIds));
}
private OutboundPlan planOutbound(String warehouseId, OutboundRequest request, boolean refresh) { private OutboundPlan planOutbound(String warehouseId, OutboundRequest request, boolean refresh) {
if (refresh) { if (refresh) {
repository.refreshChangedLanes(warehouseId); repository.refreshChangedLanes(warehouseId);
@@ -144,6 +157,11 @@ public final class WarehouseApplicationService {
} }
private OutboundPlan planOutboundFromCurrentReadModel(String warehouseId, OutboundRequest request) { private OutboundPlan planOutboundFromCurrentReadModel(String warehouseId, OutboundRequest request) {
return planOutboundFromCurrentReadModel(warehouseId, request, null);
}
private OutboundPlan planOutboundFromCurrentReadModel(String warehouseId, OutboundRequest request,
Set<String> eligibleStockIds) {
RepositoryPolicy policy = repository.loadPolicy(warehouseId); RepositoryPolicy policy = repository.loadPolicy(warehouseId);
WarehouseSummary summary = repository.loadSummary(warehouseId); WarehouseSummary summary = repository.loadSummary(warehouseId);
OutboundRequest effectiveRequest = request.fifoToleranceMinutes() == null && policy.fifoToleranceMinutes() > 0 OutboundRequest effectiveRequest = request.fifoToleranceMinutes() == null && policy.fifoToleranceMinutes() > 0
@@ -187,18 +205,28 @@ public final class WarehouseApplicationService {
matches.addAll(repository.searchOutboundStock(warehouseId, matches.addAll(repository.searchOutboundStock(warehouseId,
OutboundStockSearch.project(request.projectNo(), request.productId()))); OutboundStockSearch.project(request.projectNo(), request.productId())));
} }
if (eligibleStockIds != null) {
matches.removeIf(match -> !eligibleStockIds.contains(match.stockId()));
Set<String> resolved = matches.stream().map(OutboundStockMatch::stockId).collect(java.util.stream.Collectors.toSet());
if (!resolved.equals(eligibleStockIds)) {
throw new IllegalStateException("Captured outbound candidate is missing from the published inventory");
}
}
Set<String> matchedSpaceIds = matches.stream().map(OutboundStockMatch::spaceId)
.filter(this::nonBlank).collect(java.util.stream.Collectors.toSet());
List<String> laneCodes = matches.stream().map(OutboundStockMatch::laneCode).filter(Objects::nonNull) List<String> laneCodes = matches.stream().map(OutboundStockMatch::laneCode).filter(Objects::nonNull)
.distinct().sorted().toList(); .distinct().sorted().toList();
List<LaneSnapshot> lanes = repository.loadLaneSnapshots(warehouseId, laneCodes); List<LaneSnapshot> lanes = eligibleStockIds == null ? repository.loadLaneSnapshots(warehouseId, laneCodes)
: repository.loadShadowOutboundLaneSnapshots(warehouseId, laneCodes, matchedSpaceIds);
Set<Integer> floors = matches.stream().map(OutboundStockMatch::floorNo) Set<Integer> floors = matches.stream().map(OutboundStockMatch::floorNo)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
List<SpaceSnapshot> buffers = repository.loadRelocationBuffers(warehouseId, floors); List<SpaceSnapshot> buffers = repository.loadRelocationBuffers(warehouseId, floors);
Map<String, SpaceState> uniqueSpaces = new LinkedHashMap<>(); Map<String, SpaceState> uniqueSpaces = new LinkedHashMap<>();
Set<String> matchedSpaceIds = matches.stream().map(OutboundStockMatch::spaceId)
.filter(this::nonBlank).collect(java.util.stream.Collectors.toSet());
String productScope = matchedProductScope; String productScope = matchedProductScope;
lanes.stream().flatMap(lane -> lane.spaceStates().stream()).forEach(space -> uniqueSpaces.put(space.spaceId(), lanes.stream().flatMap(lane -> lane.spaceStates().stream()).forEach(space -> uniqueSpaces.put(space.spaceId(),
productScope != null && matchedSpaceIds.contains(space.spaceId()) eligibleStockIds != null && matchedSpaceIds.contains(space.spaceId())
? withInventoryScope(space, request.projectNo(), request.productId())
: productScope != null && matchedSpaceIds.contains(space.spaceId())
? withProductScope(space, productScope) : space)); ? withProductScope(space, productScope) : space));
buffers.stream().map(SpaceSnapshot::state).forEach(space -> uniqueSpaces.put(space.spaceId(), space)); buffers.stream().map(SpaceSnapshot::state).forEach(space -> uniqueSpaces.put(space.spaceId(), space));
Map<String, String> rawInboundTimes = new LinkedHashMap<>(); Map<String, String> rawInboundTimes = new LinkedHashMap<>();
@@ -210,7 +238,8 @@ public final class WarehouseApplicationService {
OutboundPlan plan = outboundPlanner.plan(effectiveRequest, OutboundPlan plan = outboundPlanner.plan(effectiveRequest,
new OutboundContext(List.copyOf(uniqueSpaces.values()), rawInboundTimes, new OutboundContext(List.copyOf(uniqueSpaces.values()), rawInboundTimes,
capacityMode(summary, policy), capacityMode(summary, policy),
repository.loadActiveDeepWorkflowFloors(warehouseId, request.requestId()))); repository.loadActiveDeepWorkflowFloors(warehouseId, request.requestId())),
eligibleStockIds == null ? null : matchedSpaceIds);
String resultCode = switch (plan.status()) { String resultCode = switch (plan.status()) {
case READY -> "READY"; case READY -> "READY";
case WAITING -> "WAITING_" + plan.waiting().orElseThrow().code().name(); case WAITING -> "WAITING_" + plan.waiting().orElseThrow().code().name();
@@ -312,10 +341,14 @@ public final class WarehouseApplicationService {
} }
private SpaceState withProductScope(SpaceState space, String productId) { private SpaceState withProductScope(SpaceState space, String productId) {
return withInventoryScope(space, space.projectNo(), productId);
}
private SpaceState withInventoryScope(SpaceState space, String projectNo, String productId) {
return new SpaceState(space.spaceId(), space.zoneCode(), space.laneCode(), space.depthOrder(), return new SpaceState(space.spaceId(), space.zoneCode(), space.laneCode(), space.depthOrder(),
space.laneCapacity(), space.floorNo(), space.aisleNo(), space.groupCoding(), space.rowCode(), space.laneCapacity(), space.floorNo(), space.aisleNo(), space.groupCoding(), space.rowCode(),
space.role(), space.status(), space.outboundTaskActive(), space.inventoryProjectConflict(), space.role(), space.status(), space.outboundTaskActive(), space.inventoryProjectConflict(),
space.trayNo(), space.projectNo(), productId, space.lotNo(), space.inboundAt(), space.outboundAt(), space.trayNo(), projectNo, productId, space.lotNo(), space.inboundAt(), space.outboundAt(),
space.reserved(), space.rowVersion(), space.physicalStockPresent()); space.reserved(), space.rowVersion(), space.physicalStockPresent());
} }

View File

@@ -104,6 +104,33 @@ class JdbcWarehouseRepositoryTest {
assertThat(repository.countProjectPallets("W1", "PROJECT-W2")).isZero(); assertThat(repository.countProjectPallets("W1", "PROJECT-W2")).isZero();
} }
@Test
void mixedProjectPalletIsSelectableByItsEligibleInventoryItem() throws Exception {
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('MIXED-SOURCE','W1','R','A','Mixed pallet',9,91,1,'2','',"
+ "'MIXED-TRAY','MIXED-LANE','1')");
execute("INSERT INTO BAS_STOCK VALUES ('MIX-1','MIXED-SOURCE','MIXED-TRAY','P1','SKU1','LOT','2026-01-01 09:00:00',5,0)");
execute("INSERT INTO BAS_STOCK VALUES ('MIX-2','MIXED-SOURCE','MIXED-TRAY','P2','SKU2','LOT','2026-01-01 09:00:00',5,0)");
rebuildTopology("W1");
var service = new WarehouseApplicationService(repository, new InboundPlanner(), new OutboundPlanner(), new TidyPlanner(), 100);
var request = new OutboundRequest("MIX-PICK", null, "P1", "SKU1", "OFF", null, 1, false);
var plan = service.planShadowOutboundCandidates("W1", request, Set.of("MIX-1"));
assertThat(plan.status()).isEqualTo(OutboundPlanStatus.READY);
assertThat(plan.assignment().orElseThrow().sourceSpaceId()).isEqualTo("MIXED-SOURCE");
assertThat(plan.assignment().orElseThrow().projectNo()).isEqualTo("P1");
assertThat(plan.assignment().orElseThrow().productId()).isEqualTo("SKU1");
assertThat(space(repository.loadLaneSnapshots("W1", List.of("MIXED-LANE")), "MIXED-SOURCE")
.state().outboundSelectable()).isFalse();
for (String columnValue : List.of("locked='Y'", "status='4'", "tray_code='WRONG'")) {
execute("UPDATE BAS_CARGO_SPACE SET " + columnValue + " WHERE cargo_space_id='MIXED-SOURCE'");
rebuildTopology("W1");
assertThat(service.planShadowOutboundCandidates("W1", request, Set.of("MIX-1")).status())
.isNotEqualTo(OutboundPlanStatus.READY);
execute("UPDATE BAS_CARGO_SPACE SET locked='',status='2',tray_code='MIXED-TRAY' WHERE cargo_space_id='MIXED-SOURCE'");
}
}
@Test @Test
void statusFourPhysicalOuterPalletIsPreservedAndMakesDeepOutboundWait() throws Exception { void statusFourPhysicalOuterPalletIsPreservedAndMakesDeepOutboundWait() throws Exception {
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('ALLOC-TARGET','W1','RA','A','Target',7,21,2,'2',''," execute("INSERT INTO BAS_CARGO_SPACE VALUES ('ALLOC-TARGET','W1','RA','A','Target',7,21,2,'2','',"

View File

@@ -0,0 +1,89 @@
package com.ruoyi.algorithm.service;
import com.ruoyi.algorithm.inbound.*;
import com.ruoyi.algorithm.integration.*;
import com.ruoyi.algorithm.model.*;
import com.ruoyi.algorithm.outbound.*;
import com.ruoyi.algorithm.repository.*;
import com.ruoyi.algorithm.tidy.TidyPlanner;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class ShadowCandidatePlanningTest {
@Test
void ineligibleOuterStockRemainsAPhysicalBlockerInsteadOfBeingChosenOrRemoved() {
var repository = mock(WarehouseRepository.class);
when(repository.loadPolicy("01")).thenReturn(RepositoryPolicy.defaults());
when(repository.loadSummary("01")).thenReturn(new WarehouseSummary("01", 1, 2, 2, 0, 0, 0, 0));
when(repository.searchOutboundStock(eq("01"), any())).thenReturn(List.of(
new OutboundStockMatch("INNER", "L-1", "L", 1, "T1", "P", "SKU", "LOT", null, null),
new OutboundStockMatch("OUTER", "L-2", "L", 1, "T2", "P", "SKU", "LOT", null, null)));
var source = state("L", 1, SpaceStatus.OCCUPIED, "T1", null);
var blocker = state("L", 2, SpaceStatus.OCCUPIED, "T2", "P");
when(repository.loadShadowOutboundLaneSnapshots("01", List.of("L"), Set.of("L-1")))
.thenReturn(List.of(lane("L", source, blocker)));
var service = new WarehouseApplicationService(repository, new InboundPlanner(), new OutboundPlanner(), new TidyPlanner(), 100);
var request = new OutboundRequest("O", null, "P", "SKU", "OFF", null, 1, false);
var plan = service.planShadowOutboundCandidates("01", request, Set.of("INNER"));
assertThat(plan.waiting()).isPresent();
assertThat(plan.waiting().orElseThrow().code()).isEqualTo(OutboundWaitingCode.WAITING_RELOCATION_BUFFER);
assertThat(plan.waiting().orElseThrow().details().get("sourceSpaceId")).isEqualTo("L-1");
verify(repository, never()).synchronizeReadModel(any());
verify(repository, never()).refreshChangedLanes(any());
verify(repository, never()).tryReserveInbound(any());
}
@Test
void eligibleInboundSetSurvivesProjectSizeResolutionAndPageBoundaries() {
var repository = mock(WarehouseRepository.class);
when(repository.loadPolicy("01")).thenReturn(RepositoryPolicy.defaults());
when(repository.loadSummary("01")).thenReturn(new WarehouseSummary("01", 2, 4, 0, 4, 0, 0, 0));
when(repository.pageCandidateLanes("01", null, 1)).thenReturn(new LanePage(
List.of(new LaneCandidate("01", "A", 2, 0, 2, false, false, VersionToken.numeric(1))), "A"));
when(repository.pageCandidateLanes("01", "A", 1)).thenReturn(new LanePage(
List.of(new LaneCandidate("01", "Z", 2, 0, 2, false, false, VersionToken.numeric(2))), null));
when(repository.loadLaneSnapshots("01", List.of("A"))).thenReturn(List.of(lane("A",
state("A", 1, SpaceStatus.EMPTY, null, null), state("A", 2, SpaceStatus.EMPTY, null, null))));
when(repository.loadLaneSnapshots("01", List.of("Z"))).thenReturn(List.of(lane("Z",
state("Z", 1, SpaceStatus.EMPTY, null, null), state("Z", 2, SpaceStatus.EMPTY, null, null))));
var service = new WarehouseApplicationService(repository, new InboundPlanner(), new OutboundPlanner(), new TidyPlanner(), 1);
var request = new InboundRequest("I", "T", "P", null, null, null, 1, null, Set.of(), BatchLaneMode.OPEN, Set.of("Z-1"));
assertThat(service.planInboundFromPublishedSnapshot("01", request).plan().assignment().orElseThrow().spaceId())
.isEqualTo("Z-1");
var forbidden = new InboundRequest("I", "T", "P", null, null, null, 1, null, Set.of(), BatchLaneMode.OPEN, Set.of());
assertThat(service.planInboundFromPublishedSnapshot("01", forbidden).plan().assignment()).isEmpty();
verify(repository, never()).refreshChangedLanes(any());
}
@Test
void candidateProjectionCannotBeCalledThroughLegacyOrActiveFacade() {
for (var mode : List.of(SchedulingMode.LEGACY, SchedulingMode.ACTIVE)) {
var props = new AlgorithmProperties();
props.setMode(mode);
var service = mock(WarehouseApplicationService.class);
var facade = new SchedulingFacade(service, props);
assertThatThrownBy(() -> facade.planShadowOutboundCandidates("01",
new OutboundRequest("O", null, "P", "SKU", "OFF", null, 1, false), Set.of("S")))
.isInstanceOf(IllegalStateException.class).hasMessageContaining("SHADOW");
verifyNoInteractions(service);
}
}
private LaneSnapshot lane(String code, SpaceState... spaces) {
return new LaneSnapshot("01", code, VersionToken.numeric(1), java.util.Arrays.stream(spaces)
.map(s -> new SpaceSnapshot(s, List.of(), Set.of(), s.availableEmpty(), false, null)).toList());
}
private SpaceState state(String lane, int depth, SpaceStatus status, String tray, String project) {
return new SpaceState(lane + "-" + depth, "A", lane, depth, 2, 1, 1, depth, "1", SpaceRole.STORAGE,
status, false, false, tray, project, "SKU", "LOT", Instant.EPOCH, null, false, null);
}
}

View File

@@ -1,6 +1,5 @@
package com.ruoyi.Dzp; package com.ruoyi.Dzp;
import com.ruoyi.Dzp.domain.basLed; import com.ruoyi.Dzp.domain.basLed;
import com.ruoyi.Dzp.mapper.BasLedMapper; import com.ruoyi.Dzp.mapper.BasLedMapper;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -9,7 +8,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.concurrent.locks.ReentrantLock;
@RestController @RestController
@RequestMapping("/api2dzp") @RequestMapping("/api2dzp")
@@ -17,104 +16,46 @@ public class DzpSend {
@Autowired @Autowired
private BasLedMapper mapper; private BasLedMapper mapper;
// Both scheduled tasks use the device's existing local port. A busy port is retried next tick.
private final ReentrantLock portOwner = new ReentrantLock();
@Scheduled(fixedDelay = 1000) @Scheduled(fixedDelay = 1000)
public void InLed() throws IOException { public void InLed() throws IOException { sendPending(false); }
basLed basLed = new basLed();
basLed.setSendFlag("0");
basLed.setId(1);
List<com.ruoyi.Dzp.domain.basLed> basLeds = mapper.selectBasLedList(basLed);
if (basLeds != null && basLeds.size() > 0 && (basLeds.get(0).getModel().equals("1") || basLeds.get(0).getModel().equals("2"))) {
MyUdpSocket myUdpSocket = new MyUdpSocket(6666);
ProtocolDemo.my_udp = myUdpSocket;
String ip = basLeds.get(0).getIpAddress();
String msgProductTitle = "物资编码:";
String msgProduct = basLeds.get(0).getProduct_id();
String msgSpecTitle = "物资规格:";
String msgSpec = basLeds.get(0).getSpec();
String msg3 = "托盘条码:" + basLeds.get(0).getTrayCode();
String msg4 = null;
if (basLeds.get(0).getModel().equals("1")) {
msg4 = "入库数量:";
} else {
msg4 = "出库数量:";
}
String ms7 = "物资名称:";
String msg8 = basLeds.get(0).getProductName();
String msgQuantity = basLeds.get(0).getQuantity();
if (ProtocolDemo.demo_play_chapterschedule4(ip, msgProductTitle, msgSpecTitle, msg4, msg3, msgSpec, msgProduct, ms7, msg8, msgQuantity)) {
basLeds.get(0).setSendFlag("1");
mapper.updateBasLed(basLeds.get(0));
}
myUdpSocket.close();
}
basLed basLed2 = new basLed();
basLed2.setSendFlag("0");
basLed2.setId(2);
List<com.ruoyi.Dzp.domain.basLed> basLeds2 = mapper.selectBasLedList(basLed2);
if (basLeds2 != null && basLeds2.size() > 0 && (basLeds2.get(0).getModel().equals("1") || basLeds2.get(0).getModel().equals("2"))) {
MyUdpSocket myUdpSocket = new MyUdpSocket(6666);
ProtocolDemo.my_udp = myUdpSocket;
String ip = basLeds2.get(0).getIpAddress();
String msgProductTitle = "物资编码:";
String msgProduct = basLeds2.get(0).getProduct_id();
String msgSpecTitle = "物资规格:";
String msgSpec = basLeds2.get(0).getSpec();
String msg3 = "托盘条码:" + basLeds2.get(0).getTrayCode();
String msg4 = null;
if (basLeds2.get(0).getModel().equals("1")) {
msg4 = "入库数量:";
} else {
msg4 = "出库数量:";
}
String ms7 = "物资名称:";
String msg8 = basLeds2.get(0).getProductName();
String msgQuantity = basLeds2.get(0).getQuantity();
if (ProtocolDemo.demo_play_chapterschedule4(ip, msgProductTitle, msgSpecTitle, msg4, msg3, msgSpec, msgProduct, ms7, msg8, msgQuantity)) {
basLeds2.get(0).setSendFlag("1");
mapper.updateBasLed(basLeds2.get(0));
}
myUdpSocket.close();
}
}
@Scheduled(fixedDelay = 1000) @Scheduled(fixedDelay = 1000)
public void ErrorLed() throws IOException { public void ErrorLed() throws IOException { sendPending(true); }
basLed basLed = new basLed();
basLed.setSendFlag("0");
basLed.setId(1);
List<com.ruoyi.Dzp.domain.basLed> basLeds = mapper.selectBasLedList(basLed);
if (basLeds != null && basLeds.size() > 0 && basLeds.get(0).getModel().equals("3")) {
MyUdpSocket myUdpSocket = new MyUdpSocket(6666);
ProtocolDemo.my_udp = myUdpSocket;
String ip = basLeds.get(0).getIpAddress();
if (ProtocolDemo.demo_play_chapterschedule2(ip, "报警信息", basLeds.get(0).getErrorMsg())) {
basLeds.get(0).setSendFlag("1");
mapper.updateBasLed(basLeds.get(0));
}
myUdpSocket.close();
} private void sendPending(boolean alarm) throws IOException {
basLed basLed2 = new basLed(); if (!portOwner.tryLock()) return;
basLed2.setSendFlag("0"); try {
basLed2.setId(2); for (int id = 1; id <= 2; id++) {
List<com.ruoyi.Dzp.domain.basLed> basLeds2 = mapper.selectBasLedList(basLed2); basLed query = new basLed();
if (basLeds2 != null && basLeds2.size() > 0 && basLeds2.get(0).getModel().equals("3")) { query.setId(id);
MyUdpSocket myUdpSocket = new MyUdpSocket(6666); query.setSendFlag("0");
ProtocolDemo.my_udp = myUdpSocket; var records = mapper.selectBasLedList(query);
String ip = basLeds2.get(0).getIpAddress(); if (records == null || records.isEmpty()) continue;
if (ProtocolDemo.demo_play_chapterschedule2(ip, "报警信息", basLeds2.get(0).getErrorMsg())) { basLed record = records.get(0);
basLeds2.get(0).setSendFlag("1"); boolean matchingModel = alarm ? "3".equals(record.getModel())
mapper.updateBasLed(basLeds2.get(0)); : "1".equals(record.getModel()) || "2".equals(record.getModel());
if (!matchingModel) continue;
boolean sent;
try (MyUdpSocket socket = openSocket()) {
sent = alarm
? ProtocolDemo.demo_play_chapterschedule2(socket, record.getIpAddress(), "报警信息", record.getErrorMsg())
: ProtocolDemo.demo_play_chapterschedule4(socket, record.getIpAddress(), "物资编码:", "物资规格:",
"1".equals(record.getModel()) ? "入库数量:" : "出库数量:",
"托盘条码:" + record.getTrayCode(), record.getSpec(), record.getProduct_id(),
"物资名称:", record.getProductName(), record.getQuantity());
}
if (sent) {
record.setSendFlag("1");
mapper.updateBasLed(record);
}
} }
myUdpSocket.close(); } finally {
portOwner.unlock();
} }
} }
MyUdpSocket openSocket() throws IOException { return new MyUdpSocket(6666); }
} }

View File

@@ -6,88 +6,73 @@ import java.nio.ByteBuffer;
import java.nio.channels.DatagramChannel; import java.nio.channels.DatagramChannel;
import java.nio.channels.SelectionKey; import java.nio.channels.SelectionKey;
import java.nio.channels.Selector; import java.nio.channels.Selector;
import java.util.Set;
public class MyUdpSocket { /** One exchange owns its channel, selector, receive buffer and peer address. */
private DatagramChannel channel; public class MyUdpSocket implements AutoCloseable {
private Selector selector; private final DatagramChannel channel;
private final int MAX_SIZE = 1280; private final Selector selector;
private static int client_port; private int clientPort;
private static String client_ip; private String clientIp;
ByteBuffer receive_buffer = ByteBuffer.allocate(MAX_SIZE); private final ByteBuffer receiveBuffer = ByteBuffer.allocate(1280);
public MyUdpSocket(int port) throws IOException { public MyUdpSocket(int port) throws IOException {
channel = DatagramChannel.open(); channel = DatagramChannel.open();
selector = Selector.open(); Selector opened = null;
try { try {
//<2F><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ͨ<EFBFBD><CDA8>Ϊ<EFBFBD><CEAA><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ģʽ opened = Selector.open();
channel.configureBlocking(false); channel.configureBlocking(false);
//<2F><>ȡ<EFBFBD><C8A1><EFBFBD>׽<EFBFBD><D7BD><EFBFBD>ͨ<EFBFBD><CDA8><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>׽<EFBFBD><D7BD>֣<EFBFBD><D6A3><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>׽<EFBFBD><D7BD>ְ󶨵<D6B0><F3B6A8B5><EFBFBD><EFBFBD><EFBFBD>ָ<EFBFBD><D6B8><EFBFBD>˿<EFBFBD> channel.bind(new InetSocketAddress(port));
channel.socket().bind(new InetSocketAddress(port)); channel.register(opened, SelectionKey.OP_READ);
//Ϊͨ<CEAA><CDA8>ѡ<EFBFBD><D1A1><EFBFBD><EFBFBD>ע<EFBFBD><D7A2>ͨ<EFBFBD><CDA8><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ָ<EFBFBD><D6B8><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ѡ<EFBFBD><D1A1><EFBFBD><EFBFBD><EFBFBD> selector = opened;
channel.register(selector, SelectionKey.OP_READ); } catch (IOException | RuntimeException failure) {
} catch (IOException e) { if (opened != null) {
e.printStackTrace(); try { opened.close(); } catch (IOException close) { failure.addSuppressed(close); }
}
try { channel.close(); } catch (IOException close) { failure.addSuppressed(close); }
throw failure;
} }
} }
public final void send(String ip, int port, byte[] buffer, int size) throws IOException { public final void send(String ip, int port, byte[] buffer, int size) throws IOException {
InetSocketAddress client = new InetSocketAddress(ip, port); channel.send(ByteBuffer.wrap(buffer, 0, size), new InetSocketAddress(ip, port));
channel.send(ByteBuffer.wrap(buffer, 0, size), client); //<2F><>EchoClient<6E>ظ<EFBFBD>һ<EFBFBD><D2BB><EFBFBD><EFBFBD><EFBFBD>ݱ<EFBFBD>
} }
@Override
public final void close() throws IOException { public final void close() throws IOException {
selector.close(); try { selector.close(); } finally { channel.close(); }
channel.close();
} }
public final int get_client_port() { public final int get_client_port() { return clientPort; }
return client_port; public final String get_client_ip() { return clientIp; }
}
public final String get_client_ip() {
return client_ip;
}
public final void get_receive_buffer(byte[] buffer) { public final void get_receive_buffer(byte[] buffer) {
receive_buffer.get(buffer); get_receive_packet(buffer, buffer.length);
} }
public final int receive() { public final int receive() {
try { try {
receive_buffer.clear(); receiveBuffer.clear();
if (selector.select(10) != 0) { if (selector.select(10) != 0) {
Set<SelectionKey> readyKeys = selector.selectedKeys(); var keys = selector.selectedKeys().iterator();
for (SelectionKey key : readyKeys) { while (keys.hasNext()) {
readyKeys.remove(key); SelectionKey key = keys.next();
if (key.isReadable()) { keys.remove();
DatagramChannel dc = (DatagramChannel) key.channel(); if (!key.isValid() || !key.isReadable()) continue;
InetSocketAddress client = (InetSocketAddress) dc.receive(receive_buffer); //<2F><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>һ<EFBFBD><D2BB>Client<6E><74><EFBFBD><EFBFBD><EFBFBD>ݱ<EFBFBD> InetSocketAddress peer = (InetSocketAddress) channel.receive(receiveBuffer);
if (client != null) { if (peer != null) {
client_ip = client.getAddress().getHostAddress(); clientIp = peer.getAddress().getHostAddress();
client_port = client.getPort(); clientPort = peer.getPort();
//System.out.println(client_ip+":"+client_port+" size="+receive_buffer.position()); return receiveBuffer.position();
key.interestOps(SelectionKey.OP_READ);
return receive_buffer.position();
} else {
return 0;
}
} else {
return 0;
} }
} }
} }
return 0;
} catch (IOException e) { } catch (IOException e) {
e.printStackTrace(); throw new java.io.UncheckedIOException("LED receive failed", e);
} }
return 0;
} }
public final void get_receive_packet(byte[] packet, int size) { public final void get_receive_packet(byte[] packet, int size) {
int i; System.arraycopy(receiveBuffer.array(), 0, packet, 0, size);
for (i = 0; i < size; i++) {
packet[i] = receive_buffer.array()[i];
}
} }
} }

View File

@@ -6,6 +6,7 @@ import java.awt.*;
//import java.util.Calendar; //import java.util.Calendar;
public class ProtocolDemo { public class ProtocolDemo {
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(ProtocolDemo.class);
// ====<3D><><EFBFBD>붯<EFBFBD><EBB6AF><EFBFBD><EFBFBD>ʽ<EFBFBD>б<EFBFBD>(<28><>ֵ<EFBFBD><D6B5>0<EFBFBD><30>ʼ)==== // ====<3D><><EFBFBD>붯<EFBFBD><EBB6AF><EFBFBD><EFBFBD>ʽ<EFBFBD>б<EFBFBD>(<28><>ֵ<EFBFBD><D6B5>0<EFBFBD><30>ʼ)====
// 0 = '<27><><EFBFBD>', // 0 = '<27><><EFBFBD>',
@@ -101,10 +102,19 @@ public class ProtocolDemo {
//<2F><><EFBFBD><EFBFBD>UDP<44><50><EFBFBD><EFBFBD>bufferΪ<72><CEAA><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ݣ<EFBFBD>sizeΪ<65><CEAA><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ݳ<EFBFBD><DDB3><EFBFBD> //<2F><><EFBFBD><EFBFBD>UDP<44><50><EFBFBD><EFBFBD>bufferΪ<72><CEAA><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ݣ<EFBFBD>sizeΪ<65><CEAA><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ݳ<EFBFBD><DDB3><EFBFBD>
public static int udp_receive(byte[] buffer) { public static int udp_receive(byte[] buffer) {
return udp_receive(my_udp, buffer);
}
private static int udp_receive(MyUdpSocket socket, byte[] buffer) {
int size = 0; int size = 0;
size = my_udp.receive(); try {
size = socket.receive();
} catch (java.io.UncheckedIOException e) {
log.debug("LED receive attempt failed; the bounded exchange will retry", e);
return 0;
}
if (size > 0) { if (size > 0) {
my_udp.get_receive_packet(buffer, size); socket.get_receive_packet(buffer, size);
} }
return size; return size;
} }
@@ -415,6 +425,20 @@ public class ProtocolDemo {
} }
public static boolean send_data1(String ip) { public static boolean send_data1(String ip) {
return send_data1(my_udp, ledsender, ip);
}
private static boolean udp_send1(MyUdpSocket socket, String ip, byte[] buffer, int size) {
try {
socket.send(ip, ledport, buffer, size);
return true;
} catch (java.io.IOException e) {
log.debug("LED send attempt failed; the bounded exchange will retry", e);
return false;
}
}
static boolean send_data1(MyUdpSocket socket, LEDSender2010 ledsender, String ip) {
int size = 0; int size = 0;
byte[] packet = new byte[1280]; byte[] packet = new byte[1280];
int i, j, k, x, r; int i, j, k, x, r;
@@ -435,9 +459,9 @@ public class ProtocolDemo {
ok = false; ok = false;
for (tx = 0; tx < tx_repeat; tx++) { for (tx = 0; tx < tx_repeat; tx++) {
size = ledsender.pkg_begin(packet, addr); size = ledsender.pkg_begin(packet, addr);
udp_send1(ip, packet, size); if (!udp_send1(socket, ip, packet, size)) continue;
for (x = 0; x < 50; x++) { for (x = 0; x < 50; x++) {
size = udp_receive(packet); size = udp_receive(socket, packet);
if (size > 0 && ledsender.parse_trans_respond(packet, size, LEDSender2010.PKC_BEGIN, 0) > 0) { if (size > 0 && ledsender.parse_trans_respond(packet, size, LEDSender2010.PKC_BEGIN, 0) > 0) {
ok = true; ok = true;
break; break;
@@ -459,9 +483,9 @@ public class ProtocolDemo {
for (tx = 0; tx < tx_repeat; tx++) { for (tx = 0; tx < tx_repeat; tx++) {
j = i; j = i;
size = ledsender.pkg_data(packet, addr, i, 512); size = ledsender.pkg_data(packet, addr, i, 512);
udp_send1(ip, packet, size); if (!udp_send1(socket, ip, packet, size)) continue;
for (x = 0; x < 50; x++) { for (x = 0; x < 50; x++) {
size = udp_receive(packet); size = udp_receive(socket, packet);
if (size > 0) { if (size > 0) {
r = ledsender.parse_trans_respond(packet, size,LEDSender2010.PKC_DATA, i); r = ledsender.parse_trans_respond(packet, size,LEDSender2010.PKC_DATA, i);
switch (r) { switch (r) {
@@ -490,9 +514,9 @@ public class ProtocolDemo {
ok = false; ok = false;
for (tx = 0; tx < tx_repeat; tx++) { for (tx = 0; tx < tx_repeat; tx++) {
size = ledsender.pkg_end(packet, addr, i); size = ledsender.pkg_end(packet, addr, i);
udp_send1(ip, packet, size); if (!udp_send1(socket, ip, packet, size)) continue;
for (x = 0; x < 50; x++) { for (x = 0; x < 50; x++) {
size = udp_receive(packet); size = udp_receive(socket, packet);
if (size > 0 && ledsender.parse_trans_respond(packet, size, LEDSender2010.PKC_END, i) > 0) { if (size > 0 && ledsender.parse_trans_respond(packet, size, LEDSender2010.PKC_END, i) > 0) {
ok = true; ok = true;
break; break;
@@ -870,6 +894,11 @@ public class ProtocolDemo {
//<2F>Զ<EFBFBD><D4B6>巽<EFBFBD><E5B7BD> <20><>̬<EFBFBD><CCAC>ʾ<EFBFBD><CABE><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ϣ //<2F>Զ<EFBFBD><D4B6>巽<EFBFBD><E5B7BD> <20><>̬<EFBFBD><CCAC>ʾ<EFBFBD><CABE><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ϣ
public static boolean demo_play_chapterschedule2(String ip, String msg1, String msg2) { public static boolean demo_play_chapterschedule2(String ip, String msg1, String msg2) {
return demo_play_chapterschedule2(my_udp, ip, msg1, msg2);
}
public static boolean demo_play_chapterschedule2(MyUdpSocket socket, String ip, String msg1, String msg2) {
LEDSender2010 ledsender = new LEDSender2010();
//<2F><><EFBFBD>ɽ<EFBFBD>Ŀ<EFBFBD><C4BF><EFBFBD><EFBFBD> //<2F><><EFBFBD>ɽ<EFBFBD>Ŀ<EFBFBD><C4BF><EFBFBD><EFBFBD>
ledsender.MakeRoot(LEDSender2010.ROOT_PLAY,LEDSender2010.COLOR_TYPE_DOUBLE); ledsender.MakeRoot(LEDSender2010.ROOT_PLAY,LEDSender2010.COLOR_TYPE_DOUBLE);
@@ -882,7 +911,7 @@ public class ProtocolDemo {
ledsender.AddText(0, 11, 192, 32, msg1, "<EFBFBD><EFBFBD><EFBFBD><EFBFBD>", 16, Color.BLACK, Color.YELLOW, 0, 2, false, 1, 0, 2, 0, 0, 5, 10000); ledsender.AddText(0, 11, 192, 32, msg1, "<EFBFBD><EFBFBD><EFBFBD><EFBFBD>", 16, Color.BLACK, Color.YELLOW, 0, 2, false, 1, 0, 2, 0, 0, 5, 10000);
ledsender.AddText(0, 37, 192, 32, msg2, "<EFBFBD><EFBFBD><EFBFBD><EFBFBD>", 16, Color.BLACK, Color.YELLOW, 0, 2, false, 1, 0, 2, 0, 0, 5, 10000); ledsender.AddText(0, 37, 192, 32, msg2, "<EFBFBD><EFBFBD><EFBFBD><EFBFBD>", 16, Color.BLACK, Color.YELLOW, 0, 2, false, 1, 0, 2, 0, 0, 5, 10000);
return send_data1(ip); return send_data1(socket, ledsender, ip);
} }
//<2F>Զ<EFBFBD><D4B6>巽<EFBFBD><E5B7BD><EFBFBD><EFBFBD>ʾ<EFBFBD><CABE><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> //<2F>Զ<EFBFBD><D4B6>巽<EFBFBD><E5B7BD><EFBFBD><EFBFBD>ʾ<EFBFBD><CABE><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
@@ -931,6 +960,11 @@ public class ProtocolDemo {
public static boolean demo_play_chapterschedule4(String ip, String msg1, String msg2, String msg3, String msg,String msg5,String msg6,String msg7,String msg8, String msgQuantity) { public static boolean demo_play_chapterschedule4(String ip, String msg1, String msg2, String msg3, String msg,String msg5,String msg6,String msg7,String msg8, String msgQuantity) {
return demo_play_chapterschedule4(my_udp, ip, msg1, msg2, msg3, msg, msg5, msg6, msg7, msg8, msgQuantity);
}
public static boolean demo_play_chapterschedule4(MyUdpSocket socket, String ip, String msg1, String msg2, String msg3, String msg, String msg5, String msg6, String msg7, String msg8, String msgQuantity) {
LEDSender2010 ledsender = new LEDSender2010();
//<2F><><EFBFBD>ɽ<EFBFBD>Ŀ<EFBFBD><C4BF><EFBFBD><EFBFBD> //<2F><><EFBFBD>ɽ<EFBFBD>Ŀ<EFBFBD><C4BF><EFBFBD><EFBFBD>
ledsender.MakeRoot(LEDSender2010.ROOT_PLAY,LEDSender2010.COLOR_TYPE_DOUBLE); ledsender.MakeRoot(LEDSender2010.ROOT_PLAY,LEDSender2010.COLOR_TYPE_DOUBLE);
@@ -951,7 +985,7 @@ public class ProtocolDemo {
ledsender.AddText(65, 60, 192, 21, msg8, "<EFBFBD><EFBFBD><EFBFBD><EFBFBD>", 13, Color.BLACK, Color.YELLOW, 0, 2, false, 1, 0, 7, 0, 0, 5, 3000); ledsender.AddText(65, 60, 192, 21, msg8, "<EFBFBD><EFBFBD><EFBFBD><EFBFBD>", 13, Color.BLACK, Color.YELLOW, 0, 2, false, 1, 0, 7, 0, 0, 5, 3000);
return send_data1(ip); return send_data1(socket, ledsender, ip);
} }

View File

@@ -0,0 +1,41 @@
package com.ruoyi.algorithm.shadow;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
/** Bounded evidence in the existing decision reason; no live business reads or extra DB writes. */
final class ShadowCandidateEvidence {
private static final ObjectMapper JSON = new ObjectMapper();
static String describe(List<ShadowStockReference> candidates) {
var rows = candidates.stream().map(c -> new Candidate(c.getStockId(), c.getSpaceId(), c.getTrayNo(),
c.getProjectNo(), c.getProductId(), c.getAvailableQuantity(), c.getTrayItemCount(), c.getTrayJobCount(),
c.getSpare1(), c.getSpare2(), c.getSpare3(), c.getVoltage(), c.getPhase())).toList();
try {
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(JSON.writeValueAsBytes(rows)));
List<Candidate> retained = new ArrayList<>();
String detail = "[]";
for (Candidate row : rows) {
retained.add(row);
String proposed = JSON.writeValueAsString(retained);
if (proposed.length() > 1100) { retained.remove(retained.size() - 1); break; }
detail = proposed;
}
return "candidateCount=" + rows.size() + "; candidateSha256=" + hash
+ "; candidateEvidenceComplete=" + (retained.size() == rows.size())
+ "; candidateEvidence=" + detail;
} catch (JsonProcessingException | NoSuchAlgorithmException e) {
throw new IllegalStateException("Could not capture outbound candidate evidence", e);
}
}
private record Candidate(String stock, String space, String tray, String project, String product,
BigDecimal availableQuantity, Integer trayItems, Integer trayJobs,
String spare1, String spare2, String spare3, String voltage, String phase) { }
}

View File

@@ -99,6 +99,13 @@ public class ShadowPlanningEngine {
} }
List<ShadowStockReference> candidates = inputMapper.selectProjectOutboundCandidates( List<ShadowStockReference> candidates = inputMapper.selectProjectOutboundCandidates(
warehouseId, snapshot.generation(), stockoutDetailId); warehouseId, snapshot.generation(), stockoutDetailId);
return withCandidateEvidence(planProjectCandidates(snapshot, stockoutDetailId, reference, candidates), candidates);
});
}
private OutboundShadowObservation planProjectCandidates(PublishedShadowSnapshot snapshot, String stockoutDetailId,
ShadowStockReference reference, List<ShadowStockReference> candidates) {
String warehouseId = snapshot.warehouseId();
if (candidates.size() > MAX_OUTBOUND_CANDIDATES) { if (candidates.size() > MAX_OUTBOUND_CANDIDATES) {
return unavailableProject(snapshot, stockoutDetailId, reference, return unavailableProject(snapshot, stockoutDetailId, reference,
"SKIPPED_CANDIDATE_LIMIT", "Eligible candidate set exceeds 200 rows"); "SKIPPED_CANDIDATE_LIMIT", "Eligible candidate set exceeds 200 rows");
@@ -117,16 +124,33 @@ public class ShadowPlanningEngine {
return unavailableProject(snapshot, stockoutDetailId, reference, return unavailableProject(snapshot, stockoutDetailId, reference,
"SKIPPED_UNSUPPORTED_PRODUCT_SCOPE", "Planner requires one nonblank candidate product"); "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. List<ShadowStockReference> idle = candidates.stream()
// Keep it unchanged; validate its proposal against the generation-qualified candidate set. .filter(candidate -> Integer.valueOf(0).equals(candidate.getTrayJobCount())).toList();
if (idle.isEmpty()) {
return unavailableProject(snapshot, stockoutDetailId, reference,
"SKIPPED_EXISTING_TRAY_JOB", "Existing tray jobs require unsupported delivery compatibility checks");
}
List<ShadowStockReference> sufficient = idle.stream().filter(candidate -> candidate.getAvailableQuantity() != null
&& candidate.getAvailableQuantity().compareTo(reference.getRequestedQuantity()) >= 0).toList();
if (sufficient.isEmpty()) {
return unavailableProject(snapshot, stockoutDetailId, reference,
"SKIPPED_MULTI_STOCK_REQUEST", "No single eligible stock row covers the remaining quantity");
}
if (sufficient.stream().anyMatch(candidate -> isBlank(candidate.getStockId()) || isBlank(candidate.getSpaceId())
|| isBlank(candidate.getTrayNo()) || candidate.getTrayItemCount() == null || candidate.getTrayItemCount() < 1)
|| sufficient.stream().map(ShadowStockReference::getSpaceId).distinct().count() != sufficient.size()) {
return unavailableProject(snapshot, stockoutDetailId, reference,
"SKIPPED_AMBIGUOUS_STOCK_SCOPE", "Each eligible source must identify exactly one sufficient stock row");
}
OutboundRequest request = new OutboundRequest(stockoutDetailId, null, OutboundRequest request = new OutboundRequest(stockoutDetailId, null,
projects.get(0), products.get(0), null, null, null, false); projects.get(0), products.get(0), null, null, null, false);
OutboundPlan plan = schedulingFacade.planOutboundFromPublishedSnapshot(warehouseId, request); OutboundPlan plan = schedulingFacade.planShadowOutboundCandidates(warehouseId, request,
sufficient.stream().map(ShadowStockReference::getStockId).collect(java.util.stream.Collectors.toSet()));
if (plan.assignment().isEmpty()) { if (plan.assignment().isEmpty()) {
return toOutboundObservation(snapshot, stockoutDetailId, reference, plan); return toOutboundObservation(snapshot, stockoutDetailId, reference, plan);
} }
var assignment = plan.assignment().orElseThrow(); var assignment = plan.assignment().orElseThrow();
var selected = candidates.stream().filter(candidate -> var selected = sufficient.stream().filter(candidate ->
Objects.equals(candidate.getSpaceId(), assignment.sourceSpaceId()) Objects.equals(candidate.getSpaceId(), assignment.sourceSpaceId())
&& Objects.equals(candidate.getTrayNo(), assignment.trayNo()) && Objects.equals(candidate.getTrayNo(), assignment.trayNo())
&& Objects.equals(candidate.getProjectNo(), assignment.projectNo()) && Objects.equals(candidate.getProjectNo(), assignment.projectNo())
@@ -140,10 +164,6 @@ public class ShadowPlanningEngine {
return unavailableProject(snapshot, stockoutDetailId, reference, return unavailableProject(snapshot, stockoutDetailId, reference,
"SKIPPED_EXISTING_TRAY_JOB", "Existing tray jobs require unsupported delivery compatibility checks"); "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 if (stock.getAvailableQuantity() == null
|| stock.getAvailableQuantity().compareTo(reference.getRequestedQuantity()) < 0) { || stock.getAvailableQuantity().compareTo(reference.getRequestedQuantity()) < 0) {
return unavailableProject(snapshot, stockoutDetailId, reference, return unavailableProject(snapshot, stockoutDetailId, reference,
@@ -157,7 +177,14 @@ public class ShadowPlanningEngine {
return new OutboundShadowObservation(true, snapshot.generation(), warehouseId, return new OutboundShadowObservation(true, snapshot.generation(), warehouseId,
snapshot.synchronizedAt(), snapshot.sourceSignature(), stockoutDetailId, snapshot.synchronizedAt(), snapshot.sourceSignature(), stockoutDetailId,
"READY", stock.getStockId(), stock.getSpaceId(), reason.toString()); "READY", stock.getStockId(), stock.getSpaceId(), reason.toString());
}); }
private OutboundShadowObservation withCandidateEvidence(OutboundShadowObservation observation,
List<ShadowStockReference> candidates) {
return new OutboundShadowObservation(observation.observed(), observation.snapshotGeneration(),
observation.warehouseId(), observation.snapshotSynchronizedAt(), observation.sourceSignature(),
observation.requestId(), observation.resultCode(), observation.proposedStockId(), observation.proposedSpaceId(),
abbreviate(observation.reason(), 1900) + "; " + ShadowCandidateEvidence.describe(candidates));
} }
private OutboundShadowObservation unavailableProject(PublishedShadowSnapshot snapshot, String requestId, private OutboundShadowObservation unavailableProject(PublishedShadowSnapshot snapshot, String requestId,
@@ -195,8 +222,7 @@ public class ShadowPlanningEngine {
UUID.randomUUID().toString(), observation.warehouseId(), "INBOUND_COMPARE", UUID.randomUUID().toString(), observation.warehouseId(), "INBOUND_COMPARE",
observation.snapshotGeneration(), observation.snapshotSynchronizedAt(), observation.snapshotGeneration(), observation.snapshotSynchronizedAt(),
observation.sourceSignature(), observation.sourceSignature(),
observation.observed() && "READY".equals(observation.resultCode()) && "10".equals(legacyResultCode) inboundComparisonCode(observation, legacyResultCode, legacySpaceId),
? comparisonCode(observation.proposedSpaceId(), legacySpaceId) : "NOT_COMPARABLE",
inboundComparisonReason(observation, legacyResultCode, legacySpaceId)); inboundComparisonReason(observation, legacyResultCode, legacySpaceId));
requireComparisonInserted(inserted); requireComparisonInserted(inserted);
}); });
@@ -244,22 +270,29 @@ public class ShadowPlanningEngine {
return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_MISSING_TASK_DETAILS", return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_MISSING_TASK_DETAILS",
"Job was captured but has no task details"); "Job was captured but has no task details");
} }
if (!Integer.valueOf(1).equals(reference.getFieldGroupCount())) { if (reference.getFieldGroupCount() == null || reference.getFieldGroupCount() < 1
return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_AMBIGUOUS_LEGACY_FIELDS", || reference.getProductScopeCount() == null || reference.getProductScopeCount() < 1) {
"Legacy TOP (1) has no ordering and task details have different spare1/2/3 values"); return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_MISSING_PREDICATE_INPUT",
} "Task field or product scope counts are missing");
if (!Integer.valueOf(1).equals(reference.getProductScopeCount()) || isBlank(reference.getProductId())) {
return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_MULTI_PRODUCT_INPUT",
"Single-product planner cannot represent this task product/lot scope");
} }
if (isBlank(reference.getProjectNo()) || "999".equals(reference.getProjectNo().trim()) if (isBlank(reference.getProjectNo()) || "999".equals(reference.getProjectNo().trim())
|| "COMMON".equalsIgnoreCase(reference.getProjectNo().trim())) { || "COMMON".equalsIgnoreCase(reference.getProjectNo().trim())) {
return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_UNSUPPORTED_COMMON_PROJECT", return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_UNSUPPORTED_COMMON_PROJECT",
"Common-project mapping and pool policy require separate validation"); "Common-project mapping and pool policy require separate validation");
} }
boolean ambiguousFields = reference.getFieldGroupCount() > 1;
boolean multipleProducts = reference.getProductScopeCount() > 1;
List<String> eligibleSpaces = inputMapper.selectInboundEligibleSpaces(warehouseId,
snapshot.generation(), taskNo, trayNo);
if (eligibleSpaces.isEmpty()) {
return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_NO_CONSERVATIVE_SPACE",
ambiguousFields ? "No fully empty lane; Legacy TOP (1) field choice remains unknown"
: "No space satisfies the captured conservative Legacy predicates");
}
InboundRequest request = new InboundRequest(reference.getRequestId(), reference.getTrayNo(), InboundRequest request = new InboundRequest(reference.getRequestId(), reference.getTrayNo(),
reference.getProjectNo(), reference.getProductId(), reference.getLotNo(), reference.getProjectNo(), multipleProducts ? null : reference.getProductId(),
null, null, null, Set.of(), null); multipleProducts ? null : reference.getLotNo(),
null, null, null, Set.of(), null, Set.copyOf(eligibleSpaces));
InboundPlanningResult result = schedulingFacade.planInboundFromPublishedSnapshot(warehouseId, request); InboundPlanningResult result = schedulingFacade.planInboundFromPublishedSnapshot(warehouseId, request);
InboundPlan plan = result.plan(); InboundPlan plan = result.plan();
if (plan.assignment().isPresent()) { if (plan.assignment().isPresent()) {
@@ -272,9 +305,11 @@ public class ShadowPlanningEngine {
} }
return new InboundShadowObservation(true, snapshot.generation(), warehouseId, return new InboundShadowObservation(true, snapshot.generation(), warehouseId,
snapshot.synchronizedAt(), snapshot.sourceSignature(), snapshot.synchronizedAt(), snapshot.sourceSignature(),
reference.getRequestId(), "READY", assignment.spaceId(), reference.getRequestId(), ambiguousFields ? "READY_CONSERVATIVE" : "READY", assignment.spaceId(),
inboundDiagnostic(snapshot, taskNo, trayNo, reference, inboundDiagnostic(snapshot, taskNo, trayNo, reference,
"legacyEligibility=STRICT_GROUP_OR_EMPTY; " + assignment.reason())); "legacyEligibility=" + (ambiguousFields ? "EMPTY_LANE_ONLY" : "STRICT_GROUP_OR_EMPTY")
+ "; inputScope=" + (multipleProducts ? "PROJECT_MULTI_ITEM_OPEN" : "SINGLE_ITEM")
+ "; eligibleSpaceCount=" + eligibleSpaces.size() + "; " + assignment.reason()));
} }
var rejection = plan.rejection().orElseThrow(); var rejection = plan.rejection().orElseThrow();
return InboundShadowObservation.unavailable(snapshot, return InboundShadowObservation.unavailable(snapshot,
@@ -555,6 +590,16 @@ public class ShadowPlanningEngine {
return shadowSpaceId.equals(legacySpaceId) ? "MATCH" : "DIFFERENT"; return shadowSpaceId.equals(legacySpaceId) ? "MATCH" : "DIFFERENT";
} }
private String inboundComparisonCode(InboundShadowObservation observation, String legacyCode, String legacySpace) {
if (!observation.observed() || !"10".equals(legacyCode)) return "NOT_COMPARABLE";
if (!"READY".equals(observation.resultCode()) && !"READY_CONSERVATIVE".equals(observation.resultCode())) {
return "NOT_COMPARABLE";
}
String result = comparisonCode(observation.proposedSpaceId(), legacySpace);
return "READY_CONSERVATIVE".equals(observation.resultCode()) && !"NOT_COMPARABLE".equals(result)
? result + "_CONSERVATIVE" : result;
}
private boolean isBlank(String value) { private boolean isBlank(String value) {
return value == null || value.isBlank(); return value == null || value.isBlank();
} }

View File

@@ -19,6 +19,10 @@ public interface ShadowInputMapper {
@Param("generation") long generation, @Param("taskNo") String taskNo, @Param("generation") long generation, @Param("taskNo") String taskNo,
@Param("trayNo") String trayNo, @Param("spaceId") String spaceId); @Param("trayNo") String trayNo, @Param("spaceId") String spaceId);
List<String> selectInboundEligibleSpaces(@Param("warehouseId") String warehouseId,
@Param("generation") long generation, @Param("taskNo") String taskNo,
@Param("trayNo") String trayNo);
ShadowStockReference selectProjectOutboundReference(@Param("warehouseId") String warehouseId, ShadowStockReference selectProjectOutboundReference(@Param("warehouseId") String warehouseId,
@Param("generation") long generation, @Param("generation") long generation,
@Param("stockoutDetailId") String stockoutDetailId); @Param("stockoutDetailId") String stockoutDetailId);

View File

@@ -32,6 +32,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<!-- Conservative subset valid in either Legacy project-count branch. Ranking remains in the planner. --> <!-- Conservative subset valid in either Legacy project-count branch. Ranking remains in the planner. -->
<select id="countInboundEligibleSpace" resultType="int"> <select id="countInboundEligibleSpace" resultType="int">
select count(*) select count(*)
<include refid="inboundEligibleSpaces"/>
and cargo.space_id = #{spaceId}
</select>
<select id="selectInboundEligibleSpaces" resultType="string">
select cargo.space_id
<include refid="inboundEligibleSpaces"/>
order by cargo.space_id
</select>
<sql id="inboundEligibleSpaces">
from alg.shadow_inbound_request_snapshot input from alg.shadow_inbound_request_snapshot input
join alg.cargo_space_snapshot cargo join alg.cargo_space_snapshot cargo
on cargo.warehouse_id = input.warehouse_id on cargo.warehouse_id = input.warehouse_id
@@ -39,11 +50,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where input.warehouse_id = #{warehouseId} and input.snapshot_generation = #{generation} where input.warehouse_id = #{warehouseId} and input.snapshot_generation = #{generation}
and input.task_no = #{taskNo} and input.tray_no = #{trayNo} and input.task_no = #{taskNo} and input.tray_no = #{trayNo}
and input.predicate_version = 1 and input.detail_count &gt; 0 and input.predicate_version = 1 and input.detail_count &gt; 0
and input.field_group_count = 1 and input.product_scope_count = 1 and input.field_group_count &gt; 0 and input.product_scope_count &gt; 0
and input.tray_job_count = 1 and input.legacy_scope_complete = 1 and input.tray_job_count = 1 and input.legacy_scope_complete = 1
and nullif(ltrim(rtrim(input.project_no)), '') is not null and nullif(ltrim(rtrim(input.project_no)), '') is not null
and input.project_no not in ('999', 'COMMON') and input.project_no not in ('999', 'COMMON')
and cargo.space_id = #{spaceId} and cargo.space_status = '1' and cargo.locked = 'N' and cargo.space_status = '1' and cargo.locked = 'N'
and nullif(ltrim(rtrim(cargo.tray_code)), '') is null and nullif(ltrim(rtrim(cargo.tray_code)), '') is null
and cargo.this_cargo_area in ('A', 'C') and cargo.this_cargo_area in ('A', 'C')
and nullif(ltrim(rtrim(cargo.lane_code)), '') is not null and nullif(ltrim(rtrim(cargo.lane_code)), '') is not null
@@ -68,7 +79,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
or exists (select 1 from alg.inventory_item_snapshot stock or exists (select 1 from alg.inventory_item_snapshot stock
where stock.warehouse_id = lane.warehouse_id where stock.warehouse_id = lane.warehouse_id
and stock.snapshot_generation = lane.snapshot_generation and stock.space_id = lane.space_id))) and stock.snapshot_generation = lane.snapshot_generation and stock.space_id = lane.space_id)))
or exists ( or (input.field_group_count = 1 and exists (
select 1 from alg.cargo_space_snapshot resident select 1 from alg.cargo_space_snapshot resident
join alg.inventory_item_snapshot stock join alg.inventory_item_snapshot stock
on stock.warehouse_id = resident.warehouse_id and stock.snapshot_generation = resident.snapshot_generation on stock.warehouse_id = resident.warehouse_id and stock.snapshot_generation = resident.snapshot_generation
@@ -77,8 +88,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and resident.lane_code = cargo.lane_code and resident.space_status = '2' and resident.lane_code = cargo.lane_code and resident.space_status = '2'
and resident.this_cargo_area in ('A', 'C') and resident.this_cargo_area in ('A', 'C')
and stock.project_no = input.project_no and stock.project_no = input.project_no
and stock.spare1 = input.spare1 and stock.spare2 = input.spare2 and stock.spare3 = input.spare3)) and stock.spare1 = input.spare1 and stock.spare2 = input.spare2 and stock.spare3 = input.spare3)))
</select> </sql>
<sql id="outboundCandidatePredicate"> <sql id="outboundCandidatePredicate">
item.warehouse_id = #{warehouseId} item.warehouse_id = #{warehouseId}
@@ -130,6 +141,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
select top (201) select top (201)
item.stock_id as stockId, item.tray_code as trayNo, item.space_id as spaceId, 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.project_no as projectNo, item.product_no as productId,
item.spare1, item.spare2, item.spare3, item.voltage, item.phase,
item.tray_job_count as trayJobCount, item.tray_job_count as trayJobCount,
item.quantity - item.out_quantity as availableQuantity, item.quantity - item.out_quantity as availableQuantity,
(select count(*) from alg.inventory_item_snapshot trayItem (select count(*) from alg.inventory_item_snapshot trayItem

View File

@@ -0,0 +1,98 @@
package com.ruoyi.Dzp;
import com.ruoyi.Dzp.domain.basLed;
import com.ruoyi.Dzp.mapper.BasLedMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class DzpSendTest {
@Test
void failedExchangeClosesItsSocketAndDoesNotAcknowledgeTheDisplay() throws Exception {
var socket = mock(MyUdpSocket.class);
var sender = new DzpSend() { @Override MyUdpSocket openSocket() { return socket; } };
var mapper = mock(BasLedMapper.class);
ReflectionTestUtils.setField(sender, "mapper", mapper);
var record = new basLed(); record.setModel("3"); record.setSendFlag("0"); record.setIpAddress("127.0.0.1"); record.setErrorMsg("alarm");
when(mapper.selectBasLedList(any())).thenAnswer(call -> call.getArgument(0, basLed.class).getId() == 1 ? List.of(record) : List.of());
try (var protocol = mockStatic(ProtocolDemo.class)) {
protocol.when(() -> ProtocolDemo.demo_play_chapterschedule2(socket, "127.0.0.1", "报警信息", "alarm"))
.thenThrow(new IllegalStateException("test send failure")).thenReturn(true);
assertThatThrownBy(sender::ErrorLed).hasMessage("test send failure");
verify(socket).close();
verify(mapper, never()).updateBasLed(any());
assertThat(record.getSendFlag()).isEqualTo("0");
sender.ErrorLed();
verify(socket, times(2)).close();
verify(mapper).updateBasLed(record);
}
}
@Test
void busyLocalPortDefersOtherTimerWithoutBlockingOrChangingRows() throws Exception {
var entered = new CountDownLatch(1);
var release = new CountDownLatch(1);
var sender = new DzpSend() {
@Override MyUdpSocket openSocket() throws IOException {
entered.countDown();
try { if (!release.await(3, TimeUnit.SECONDS)) throw new IOException("test timeout"); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException(e); }
throw new IOException("fixture port failure");
}
};
var mapper = mock(BasLedMapper.class);
ReflectionTestUtils.setField(sender, "mapper", mapper);
var record = new basLed(); record.setModel("1"); record.setSendFlag("0");
when(mapper.selectBasLedList(any())).thenReturn(List.of(record));
var executor = Executors.newFixedThreadPool(2);
try {
var first = executor.submit(() -> { assertThatThrownBy(sender::InLed).isInstanceOf(IOException.class); });
assertThat(entered.await(2, TimeUnit.SECONDS)).isTrue();
executor.submit(() -> { sender.ErrorLed(); return null; }).get(1, TimeUnit.SECONDS);
verify(mapper, times(1)).selectBasLedList(any());
verify(mapper, never()).updateBasLed(any());
release.countDown();
first.get(2, TimeUnit.SECONDS);
} finally { release.countDown(); executor.shutdownNow(); }
}
@Test
void eachScheduledMessageBuildsItsOwnPayload() throws Exception {
try (var payloads = mockConstruction(LEDSender2010.class,
(sender, context) -> when(sender.GetDataSize()).thenReturn((1 << 21) + 1))) {
var socket = mock(MyUdpSocket.class);
assertThat(ProtocolDemo.demo_play_chapterschedule2(socket, "127.0.0.1", "title1", "one")).isFalse();
assertThat(ProtocolDemo.demo_play_chapterschedule2(socket, "127.0.0.1", "title2", "two")).isFalse();
assertThat(payloads.constructed()).hasSize(2);
verify(payloads.constructed().get(0)).GetDataSize();
verify(payloads.constructed().get(1)).GetDataSize();
verifyNoInteractions(socket);
}
}
@Test
void transientSocketErrorsStayInsideTheBoundedProtocolRetry() throws Exception {
var socket = mock(MyUdpSocket.class);
var payload = mock(LEDSender2010.class);
doThrow(new IOException("transient send")).doNothing().when(socket).send(anyString(), anyInt(), any(), anyInt());
when(socket.receive()).thenThrow(new java.io.UncheckedIOException(new IOException("transient receive"))).thenReturn(1);
when(payload.parse_trans_respond(any(), anyInt(), anyInt(), anyInt())).thenReturn(1);
assertThat(ProtocolDemo.send_data1(socket, payload, "127.0.0.1")).isTrue();
verify(socket, times(3)).send(anyString(), anyInt(), any(), anyInt());
verify(socket, times(3)).receive();
}
@Test
void persistentSendFailureStopsAfterFiveAttempts() throws Exception {
var socket = mock(MyUdpSocket.class);
doThrow(new IOException("unreachable")).when(socket).send(anyString(), anyInt(), any(), anyInt());
assertThat(ProtocolDemo.send_data1(socket, mock(LEDSender2010.class), "127.0.0.1")).isFalse();
verify(socket, times(5)).send(anyString(), anyInt(), any(), anyInt());
verify(socket, never()).receive();
}
}

View File

@@ -0,0 +1,50 @@
package com.ruoyi.Dzp;
import org.junit.jupiter.api.Test;
import java.net.DatagramSocket;
import java.net.DatagramPacket;
import java.net.InetAddress;
import java.io.IOException;
import static org.assertj.core.api.Assertions.*;
class MyUdpSocketTest {
@Test
void occupiedPortFailsConstructionInsteadOfReturningBrokenSocket() throws Exception {
try (var occupied = new DatagramSocket(0)) {
assertThatThrownBy(() -> new MyUdpSocket(occupied.getLocalPort()))
.isInstanceOf(IOException.class);
}
}
@Test
void receivedPeerBelongsToItsOwnSocket() throws Exception {
MyUdpSocket first = new MyUdpSocket(0);
MyUdpSocket second = new MyUdpSocket(0);
try (var peerOne = new DatagramSocket(0); var peerTwo = new DatagramSocket(0)) {
exchange(first, peerOne, (byte) 11);
int firstPeer = first.get_client_port();
exchange(second, peerTwo, (byte) 22);
assertThat(first.get_client_port()).isEqualTo(firstPeer);
assertThat(second.get_client_port()).isEqualTo(peerTwo.getLocalPort());
first.close();
exchange(second, peerTwo, (byte) 33);
} finally {
first.close();
second.close();
}
}
private void exchange(MyUdpSocket socket, DatagramSocket peer, byte value) throws Exception {
peer.setSoTimeout(1000);
socket.send("127.0.0.1", peer.getLocalPort(), new byte[]{value}, 1);
var request = new DatagramPacket(new byte[64], 64);
peer.receive(request);
peer.send(new DatagramPacket(new byte[]{value}, 1, InetAddress.getLoopbackAddress(), request.getPort()));
int size = 0;
for (int attempt = 0; attempt < 100 && size == 0; attempt++) size = socket.receive();
assertThat(size).isEqualTo(1);
byte[] result = new byte[1];
socket.get_receive_packet(result, 1);
assertThat(result).containsExactly(value);
}
}

View File

@@ -172,6 +172,23 @@ class ShadowInboundEligibilityTest {
} }
} }
@Test
void multipleProductsWithOneFieldGroupCanContinueTheMatchingProjectLane() {
jdbc.update("update alg.shadow_inbound_request_snapshot set detail_count=5,product_scope_count=5,product_id=null,lot_no=null");
space("RESIDENT", "LANE", "1", "2", "N", "OLD", "C");
stock("RESIDENT", "OLD");
assertThat(eligible()).isEqualTo(1);
}
@Test
void ambiguousFieldGroupsOnlyAllowEntirelyEmptyLanes() {
jdbc.update("update alg.shadow_inbound_request_snapshot set detail_count=5,field_group_count=2,product_scope_count=5");
assertThat(eligible()).isEqualTo(1);
space("RESIDENT", "LANE", "1", "2", "N", "OLD", "C");
stock("RESIDENT", "OLD");
assertThat(eligible()).isZero();
}
private void space(String id, String lane, String floor, String status, String locked, String tray, String area) { private void space(String id, String lane, String floor, String status, String locked, String tray, String area) {
jdbc.update("insert into alg.cargo_space_snapshot values ('01',42,?,?,?,?,?,?,?,'UNRELATED')", jdbc.update("insert into alg.cargo_space_snapshot values ('01',42,?,?,?,?,?,?,?,'UNRELATED')",
id, lane, floor, status, locked, tray, area); id, lane, floor, status, locked, tray, area);

View File

@@ -61,6 +61,7 @@ class ShadowInboundValidationTest {
reference.setTrayJobCount(1); reference.setTrayJobCount(1);
reference.setLegacyScopeComplete(1); reference.setLegacyScopeComplete(1);
when(mapper.selectInboundInput("01", 42, "31331", "TRAY-1")).thenReturn(reference); when(mapper.selectInboundInput("01", 42, "31331", "TRAY-1")).thenReturn(reference);
when(mapper.selectInboundEligibleSpaces("01", 42, "31331", "TRAY-1")).thenReturn(java.util.List.of("SPACE-9"));
when(mapper.countInboundEligibleSpace("01", 42, "31331", "TRAY-1", "SPACE-9")).thenReturn(1); when(mapper.countInboundEligibleSpace("01", 42, "31331", "TRAY-1", "SPACE-9")).thenReturn(1);
var assignment = new InboundAssignment("HPR-1", "SPACE-9", "LANE", 1, 1, var assignment = new InboundAssignment("HPR-1", "SPACE-9", "LANE", 1, 1,
InboundLadderRung.CONTINUE, InboundBorrowType.NONE, null, InboundLadderRung.CONTINUE, InboundBorrowType.NONE, null,
@@ -87,13 +88,22 @@ class ShadowInboundValidationTest {
reference.setDetailCount(45); reference.setDetailCount(45);
reference.setFieldGroupCount(2); reference.setFieldGroupCount(2);
reference.setProductScopeCount(45); reference.setProductScopeCount(45);
assertSkipped("SKIPPED_AMBIGUOUS_LEGACY_FIELDS"); var result = plan();
assertThat(result.resultCode()).isEqualTo("READY_CONSERVATIVE");
assertThat(result.reason()).contains("legacyEligibility=EMPTY_LANE_ONLY");
engine.compareInbound(result, "10", "SPACE-9");
verify(mapper).insertComparison(any(), any(), eq("INBOUND_COMPARE"), anyLong(), any(), any(),
eq("MATCH_CONSERVATIVE"), any());
} }
@Test @Test
void multiProductTrayDoesNotSilentlyUseOneRepresentativeProduct() { void multiProductTrayDoesNotSilentlyUseOneRepresentativeProduct() {
reference.setProductScopeCount(2); reference.setProductScopeCount(2);
assertSkipped("SKIPPED_MULTI_PRODUCT_INPUT"); assertThat(plan().resultCode()).isEqualTo("READY");
verify(facade).planInboundFromPublishedSnapshot(eq("01"), argThat(request ->
request.productId() == null && request.lotNo() == null
&& request.batchLaneMode() == com.ruoyi.algorithm.inbound.BatchLaneMode.OPEN
&& request.eligibleSpaceIds().equals(java.util.Set.of("SPACE-9"))));
} }
@Test @Test
@@ -148,6 +158,12 @@ class ShadowInboundValidationTest {
private InboundShadowObservation plan() { return engine.planInbound(snapshot, "31331", "TRAY-1"); } private InboundShadowObservation plan() { return engine.planInbound(snapshot, "31331", "TRAY-1"); }
@Test
void noConservativeSpaceDoesNotInventAnUnrestrictedRequest() {
when(mapper.selectInboundEligibleSpaces("01", 42, "31331", "TRAY-1")).thenReturn(java.util.List.of());
assertSkipped("SKIPPED_NO_CONSERVATIVE_SPACE");
}
private void assertSkipped(String code) { private void assertSkipped(String code) {
var result = plan(); var result = plan();
assertThat(result.resultCode()).isEqualTo(code); assertThat(result.resultCode()).isEqualTo(code);

View File

@@ -59,7 +59,7 @@ class ShadowOutboundValidationTest {
candidate.setTrayJobCount(0); candidate.setTrayJobCount(0);
when(mapper.selectProjectOutboundReference("01", 459L, "DETAIL")).thenReturn(input); when(mapper.selectProjectOutboundReference("01", 459L, "DETAIL")).thenReturn(input);
when(mapper.selectProjectOutboundCandidates("01", 459L, "DETAIL")).thenReturn(List.of(candidate)); when(mapper.selectProjectOutboundCandidates("01", 459L, "DETAIL")).thenReturn(List.of(candidate));
when(facade.planOutboundFromPublishedSnapshot(eq("01"), any())).thenReturn( when(facade.planShadowOutboundCandidates(eq("01"), any(), any())).thenReturn(
OutboundPlan.ready(new OutboundAssignment("DETAIL", "TRAY", input.getProjectNo(), "PRODUCT", OutboundPlan.ready(new OutboundAssignment("DETAIL", "TRAY", input.getProjectNo(), "PRODUCT",
"SPACE", 1, false, List.of(), List.of(), false, 0, 0, 0, "SPACE", 1, false, List.of(), List.of(), false, 0, 0, 0,
FifoDecision.off(), Optional.empty(), "proposal"), TaskCountEstimate.zero())); FifoDecision.off(), Optional.empty(), "proposal"), TaskCountEstimate.zero()));
@@ -71,7 +71,7 @@ class ShadowOutboundValidationTest {
assertThat(result.resultCode()).isEqualTo("READY"); assertThat(result.resultCode()).isEqualTo("READY");
assertThat(result.proposedStockId()).isEqualTo("STOCK"); assertThat(result.proposedStockId()).isEqualTo("STOCK");
assertThat(result.reason()).contains("predicateVersion=1", "requestedQuantity=3", "selectedProject=AD.L-00033.01.01"); assertThat(result.reason()).contains("predicateVersion=1", "requestedQuantity=3", "selectedProject=AD.L-00033.01.01");
verify(facade).planOutboundFromPublishedSnapshot(eq("01"), argThat(r -> !r.wholeTrayOrDefault())); verify(facade).planShadowOutboundCandidates(eq("01"), argThat(r -> !r.wholeTrayOrDefault()), eq(java.util.Set.of("STOCK")));
verify(mapper, never()).refreshSnapshot(anyString(), anyLong(), anyString()); verify(mapper, never()).refreshSnapshot(anyString(), anyLong(), anyString());
verify(facade, never()).planOutbound(anyString(), any()); verify(facade, never()).planOutbound(anyString(), any());
} }
@@ -80,7 +80,7 @@ class ShadowOutboundValidationTest {
void emptyProjectDerivesOnlyProvenCandidateScope() { void emptyProjectDerivesOnlyProvenCandidateScope() {
input.setProjectNo(null); input.setProjectNo(null);
assertThat(plan().resultCode()).isEqualTo("READY"); assertThat(plan().resultCode()).isEqualTo("READY");
verify(facade).planOutboundFromPublishedSnapshot(eq("01"), argThat(r -> "AD.L-00033.01.01".equals(r.projectNo()))); verify(facade).planShadowOutboundCandidates(eq("01"), argThat(r -> "AD.L-00033.01.01".equals(r.projectNo())), any());
} }
@Test @Test
@@ -134,9 +134,12 @@ class ShadowOutboundValidationTest {
} }
@Test @Test
void multipleRowsOnSelectedTrayAreExplicitlyUnsupported() { void otherRowsOnSelectedTrayDoNotEraseTheEligibleItem() {
candidate.setTrayItemCount(2); candidate.setTrayItemCount(2);
assertThat(plan().resultCode()).isEqualTo("SKIPPED_MULTI_STOCK_TRAY"); var result = plan();
assertThat(result.resultCode()).isEqualTo("READY");
assertThat(result.proposedStockId()).isEqualTo("STOCK");
assertThat(result.reason()).contains("candidateEvidenceComplete=true", "\"trayItems\":2", "\"stock\":\"STOCK\"");
} }
@Test @Test
@@ -155,4 +158,13 @@ class ShadowOutboundValidationTest {
private OutboundShadowObservation plan() { private OutboundShadowObservation plan() {
return engine.planProjectOutbound(snapshot, "DETAIL"); return engine.planProjectOutbound(snapshot, "DETAIL");
} }
@Test
void unsupportedDuplicateEligibleRowsRemainExplicitAndKeepEvidence() {
when(mapper.selectProjectOutboundCandidates("01", 459L, "DETAIL")).thenReturn(List.of(candidate, candidate));
var result = plan();
assertThat(result.resultCode()).isEqualTo("SKIPPED_AMBIGUOUS_STOCK_SCOPE");
assertThat(result.reason()).contains("candidateCount=2", "candidateSha256=");
verifyNoInteractions(facade);
}
} }

View File

@@ -52,6 +52,7 @@ class ShadowPlanningEngineTest {
ShadowInboundReference input = inboundReference("1"); ShadowInboundReference input = inboundReference("1");
input.setShadowCandidateCount(3); input.setShadowCandidateCount(3);
when(mapper.selectInboundInput("01", 42L, "TASK-1", "TRAY-1")).thenReturn(input); when(mapper.selectInboundInput("01", 42L, "TASK-1", "TRAY-1")).thenReturn(input);
when(mapper.selectInboundEligibleSpaces("01", 42L, "TASK-1", "TRAY-1")).thenReturn(java.util.List.of("SPACE-9"));
when(mapper.countInboundEligibleSpace("01", 42L, "TASK-1", "TRAY-1", "SPACE-9")).thenReturn(1); when(mapper.countInboundEligibleSpace("01", 42L, "TASK-1", "TRAY-1", "SPACE-9")).thenReturn(1);
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT)); when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT));
when(facade.planInboundFromPublishedSnapshot(eq("01"), any())) when(facade.planInboundFromPublishedSnapshot(eq("01"), any()))
@@ -89,7 +90,7 @@ class ShadowPlanningEngineTest {
.thenReturn(java.util.List.of(stockReference("01"))); .thenReturn(java.util.List.of(stockReference("01")));
when(mapper.selectProjectOutboundReference("01", 42L, "DETAIL-1")).thenReturn(reference); when(mapper.selectProjectOutboundReference("01", 42L, "DETAIL-1")).thenReturn(reference);
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT)); when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT));
when(facade.planOutboundFromPublishedSnapshot(eq("01"), any())) when(facade.planShadowOutboundCandidates(eq("01"), any(), any()))
.thenReturn(OutboundPlan.ready(outboundAssignment(), TaskCountEstimate.zero())); .thenReturn(OutboundPlan.ready(outboundAssignment(), TaskCountEstimate.zero()));
ShadowPlanningEngine engine = engine(facade, mapper); ShadowPlanningEngine engine = engine(facade, mapper);
@@ -104,7 +105,7 @@ class ShadowPlanningEngineTest {
"sourceSignature=ABC123", "detail=DETAIL-1", "rawWarehouse=W02", "sourceSignature=ABC123", "detail=DETAIL-1", "rawWarehouse=W02",
"project=PROJECT-1", "product=PRODUCT-1", "shadowMatches=1"); "project=PROJECT-1", "product=PRODUCT-1", "shadowMatches=1");
verify(mapper).selectProjectOutboundReference("01", 42L, "DETAIL-1"); verify(mapper).selectProjectOutboundReference("01", 42L, "DETAIL-1");
verify(facade).planOutboundFromPublishedSnapshot(eq("01"), any()); verify(facade).planShadowOutboundCandidates(eq("01"), any(), eq(java.util.Set.of("STOCK-1")));
verify(facade, never()).planOutbound(any(), any()); verify(facade, never()).planOutbound(any(), any());
} }
@@ -359,6 +360,8 @@ class ShadowPlanningEngineTest {
} }
private static ShadowPlanningEngine engine(SchedulingFacade facade, ShadowInputMapper mapper) { private static ShadowPlanningEngine engine(SchedulingFacade facade, ShadowInputMapper mapper) {
when(mapper.selectInboundEligibleSpaces(any(), any(Long.class), any(), any()))
.thenReturn(java.util.List.of("SPACE-9"));
return new ShadowPlanningEngine(facade, mapper, properties(), new CapturingTransactionManager()); return new ShadowPlanningEngine(facade, mapper, properties(), new CapturingTransactionManager());
} }

View File

@@ -0,0 +1,44 @@
package com.ruoyi.in.stockindetail.controller;
import com.ruoyi.framework.web.exception.GlobalExceptionHandler;
import com.ruoyi.in.stockindetail.service.IInStockinDetailService;
import com.ruoyi.in.stockindetailfinish.service.IInStockinDetailFinishService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
class InStockinDetailBindingTest {
@org.springframework.web.bind.annotation.RestController
static class BrokenServer {
@org.springframework.web.bind.annotation.GetMapping("/fixture/server-failure")
Object get() { throw new org.springframework.beans.InvalidPropertyException(BrokenServer.class,
"stockinId[limit]", "server property failure"); }
}
@Test
void serverPropertyFailureIsNotRelabeledAsBadClientInput() throws Exception {
MockMvcBuilders.standaloneSetup(new BrokenServer()).setControllerAdvice(new GlobalExceptionHandler()).build()
.perform(get("/fixture/server-failure").param("stockinId[limit]", "20"))
.andExpect(jsonPath("$.code").value(500));
}
@Test
void nestedOrderNumberIsRejectedBeforeEitherDatabaseQuery() throws Exception {
var controller = new InStockinDetailController();
var active = mock(IInStockinDetailService.class);
var finished = mock(IInStockinDetailFinishService.class);
ReflectionTestUtils.setField(controller, "inStockinDetailService", active);
ReflectionTestUtils.setField(controller, "inStockinDetailFinishService", finished);
var mvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new GlobalExceptionHandler()).build();
mvc.perform(get("/in/stockInDetail/list").param("stockinId[limit]", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(400))
.andExpect(jsonPath("$.msg").value("请求参数格式不正确,单号等普通字段请传单个值,不要传对象或数组"));
verifyNoInteractions(active, finished);
}
}

View File

@@ -3,6 +3,7 @@ package com.ruoyi.framework.web.exception;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.InvalidPropertyException;
import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.AccessDeniedException;
import org.springframework.validation.BindException; import org.springframework.validation.BindException;
import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.HttpRequestMethodNotSupportedException;
@@ -90,6 +91,23 @@ public class GlobalExceptionHandler
return AjaxResult.error(String.format("请求参数类型不匹配,参数[%s]要求类型为:'%s',但输入值为:'%s'", e.getName(), e.getRequiredType().getName(), value)); return AjaxResult.error(String.format("请求参数类型不匹配,参数[%s]要求类型为:'%s',但输入值为:'%s'", e.getName(), e.getRequiredType().getName(), value));
} }
@ExceptionHandler(InvalidPropertyException.class)
public AjaxResult handleInvalidPropertyException(InvalidPropertyException e, HttpServletRequest request)
{
String property = e.getPropertyName();
boolean bindingFailure = java.util.Arrays.stream(e.getStackTrace()).anyMatch(frame ->
frame.getClassName().equals("org.springframework.web.method.annotation.ModelAttributeMethodProcessor")
&& frame.getMethodName().equals("resolveArgument"));
if (!bindingFailure || property == null || !property.contains("[")
|| !request.getParameterMap().containsKey(property))
{
return handleRuntimeException(e, request);
}
log.warn("请求地址'{}',请求参数结构不正确", request.getRequestURI());
return AjaxResult.error(HttpStatus.BAD_REQUEST,
"请求参数格式不正确,单号等普通字段请传单个值,不要传对象或数组");
}
/** /**
* 拦截未知的运行时异常 * 拦截未知的运行时异常
*/ */