diff --git a/docs/FT546WMS-V34-REPAIR-0909-deploy.md b/docs/FT546WMS-V34-REPAIR-0909-deploy.md new file mode 100644 index 0000000..7ea2f67 --- /dev/null +++ b/docs/FT546WMS-V34-REPAIR-0909-deploy.md @@ -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 仍表示源数据在刷新中变化而拒绝发布;不屏蔽该一致性保护,不增加影子刷新超时。 + +这次修复不等于批准新算法接管正式业务。两个历史出库代次已被覆盖,尚不能完整回放;请以本包启动后的新样本继续评估。至少按既有观察要求收集同版本有效出入库样本,并单独复核保守比较、跳过、差异和性能。 diff --git a/docs/superpowers/plans/2026-09-09-shadow-repair-plan.md b/docs/superpowers/plans/2026-09-09-shadow-repair-plan.md new file mode 100644 index 0000000..ac89530 --- /dev/null +++ b/docs/superpowers/plans/2026-09-09-shadow-repair-plan.md @@ -0,0 +1,12 @@ +# 现场修复实施计划 + +设计:../specs/2026-09-09-shadow-repair-design.md + +- [x] 现场证据和根因调查;干净隔离工作树基线测试通过。 +- [x] 添加查询绑定、UDP、入出库候选投影回归测试,确认失败。 +- [x] 实现无数据库升级的修复;逐组测试。 +- [x] 完整离线构建和全部 JUnit 测试,独立审查;审查修正后的最终构建成功。 +- [ ] 生成版本化部署包并上传,新增启动项;不重启。 +- [ ] 交付切换步骤、回退方式和仍需现场验证的边界。 + +后续上传与现场配置结果以独立交付目录中的 build-verification.json / site-preparation.json 为准,避免预先声明部署完成。 diff --git a/docs/superpowers/specs/2026-09-09-shadow-repair-design.md b/docs/superpowers/specs/2026-09-09-shadow-repair-design.md new file mode 100644 index 0000000..dd0b519 --- /dev/null +++ b/docs/superpowers/specs/2026-09-09-shadow-repair-design.md @@ -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;支持的是匹配单个项目/物料的库存行处于多项目/多物料托盘的情况,不声称覆盖任意多项目订单。 diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundPlanner.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundPlanner.java index 51edcb4..74d1a24 100644 --- a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundPlanner.java +++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundPlanner.java @@ -472,6 +472,7 @@ public final class InboundPlanner { } 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 (request.targetFloor() != null && space.floorNo() != request.targetFloor()) return false; if (request.targetFloor() == null && !request.allowedFloors().isEmpty() diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundRequest.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundRequest.java index 95ac54e..665fa84 100644 --- a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundRequest.java +++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundRequest.java @@ -12,8 +12,16 @@ public record InboundRequest( Integer entryFloor, Integer targetFloor, Set allowedFloors, - BatchLaneMode batchLaneMode + BatchLaneMode batchLaneMode, + Set eligibleSpaceIds ) { + public InboundRequest(String requestId, String trayNo, String projectNo, String productId, String lotNo, + Integer expectedProjectPallets, Integer entryFloor, Integer targetFloor, + Set allowedFloors, BatchLaneMode batchLaneMode) { + this(requestId, trayNo, projectNo, productId, lotNo, expectedProjectPallets, entryFloor, targetFloor, + allowedFloors, batchLaneMode, null); + } + public InboundRequest { if (requestId == null || requestId.isBlank()) throw new IllegalArgumentException("requestId 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); batchLaneMode = batchLaneMode == null ? BatchLaneMode.OPEN : batchLaneMode; + // null means unrestricted; an empty captured set means no eligible destination. + eligibleSpaceIds = eligibleSpaceIds == null ? null : Set.copyOf(eligibleSpaceIds); } } diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/SchedulingFacade.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/SchedulingFacade.java index 4fa2f06..4e6a174 100644 --- a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/SchedulingFacade.java +++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/SchedulingFacade.java @@ -60,6 +60,14 @@ public final class SchedulingFacade { return applicationService.planOutboundFromPublishedSnapshot(warehouseId, request); } + public OutboundPlan planShadowOutboundCandidates(String warehouseId, OutboundRequest request, + java.util.Set 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) { requireAlgorithmMode(); return applicationService.planTidy(warehouseId, now); diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundPlanner.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundPlanner.java index 421f917..ce1013f 100644 --- a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundPlanner.java +++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundPlanner.java @@ -18,7 +18,14 @@ import java.util.Optional; public final class OutboundPlanner { public OutboundPlan plan(OutboundRequest request, OutboundContext context) { + return plan(request, context, null); + } + + public OutboundPlan plan(OutboundRequest request, OutboundContext context, java.util.Set eligibleSpaceIds) { 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)) { return error(OutboundErrorCode.FIFO_INVALID_MODE, "Unsupported FIFO mode", Map.of("fifoMode", String.valueOf(request.fifoMode()))); @@ -35,6 +42,7 @@ public final class OutboundPlanner { List physicalMatches = context.spaces().stream() .filter(SpaceState::occupied) + .filter(space -> eligibleSpaceIds == null || eligibleSpaceIds.contains(space.spaceId())) .filter(space -> matchesRequest(space, request)) .toList(); if (physicalMatches.isEmpty()) { diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/JdbcWarehouseRepository.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/JdbcWarehouseRepository.java index c56c89d..6c8455b 100644 --- a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/JdbcWarehouseRepository.java +++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/JdbcWarehouseRepository.java @@ -359,6 +359,17 @@ public final class JdbcWarehouseRepository implements WarehouseRepository { @Override public List loadLaneSnapshots(String warehouseId, Collection laneCodes) { + return loadLaneSnapshots(warehouseId, laneCodes, Set.of()); + } + + @Override + public List loadShadowOutboundLaneSnapshots(String warehouseId, Collection laneCodes, + Set candidateSpaceIds) { + return loadLaneSnapshots(warehouseId, laneCodes, Set.copyOf(candidateSpaceIds)); + } + + private List loadLaneSnapshots(String warehouseId, Collection laneCodes, + Set mixedProjectSources) { LinkedHashSet codes = laneCodes == null ? new LinkedHashSet<>() : laneCodes.stream().filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); if (codes.isEmpty()) return List.of(); @@ -393,7 +404,7 @@ public final class JdbcWarehouseRepository implements WarehouseRepository { int capacity = entry.getValue().size(); List spaces = entry.getValue().stream() .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()) .thenComparing(SpaceSnapshot::spaceId)).toList(); 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 inventory) { + return mapSpace(raw, capacity, version, inventory, false); + } + + private SpaceSnapshot mapSpace(RawSpace raw, int capacity, VersionToken version, + List inventory, boolean allowMixedProjectSource) { EnumSet conflicts = EnumSet.noneOf(SpaceConflict.class); if (raw.trayConflict()) conflicts.add(SpaceConflict.TRAY); 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) .min(Comparator.naturalOrder()).orElse(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(), "")) { case "1" -> SpaceStatus.EMPTY; case "2" -> SpaceStatus.OCCUPIED; diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/WarehouseRepository.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/WarehouseRepository.java index af2def9..6de7585 100644 --- a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/WarehouseRepository.java +++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/WarehouseRepository.java @@ -16,6 +16,10 @@ public interface WarehouseRepository { LanePage pageCandidateLanes(String warehouseId, String afterLaneCode, int pageSize); LanePage pageWarehouseLanes(String warehouseId, String afterLaneCode, int pageSize); List loadLaneSnapshots(String warehouseId, Collection laneCodes); + default List loadShadowOutboundLaneSnapshots(String warehouseId, Collection laneCodes, + Set candidateSpaceIds) { + return loadLaneSnapshots(warehouseId, laneCodes); + } List loadRelocationBuffers(String warehouseId, Set floors); Set loadActiveDeepWorkflowFloors(String warehouseId, String excludingRequestId); boolean acquireDeepWorkflowLock(String requestId, String warehouseId, int floorNo); diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/service/WarehouseApplicationService.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/service/WarehouseApplicationService.java index 655f216..cf6283c 100644 --- a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/service/WarehouseApplicationService.java +++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/service/WarehouseApplicationService.java @@ -98,7 +98,8 @@ public final class WarehouseApplicationService { int currentProjectPallets = repository.countProjectPallets(warehouseId, request.projectNo()); effectiveRequest = new InboundRequest(request.requestId(), request.trayNo(), request.projectNo(), 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 pending = repository.loadPendingOutboundJobs(warehouseId).stream() .map(job -> new PendingOutbound(job.projectNo(), job.productNo(), job.trayNo(), @@ -136,6 +137,18 @@ public final class WarehouseApplicationService { 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 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) { if (refresh) { repository.refreshChangedLanes(warehouseId); @@ -144,6 +157,11 @@ public final class WarehouseApplicationService { } private OutboundPlan planOutboundFromCurrentReadModel(String warehouseId, OutboundRequest request) { + return planOutboundFromCurrentReadModel(warehouseId, request, null); + } + + private OutboundPlan planOutboundFromCurrentReadModel(String warehouseId, OutboundRequest request, + Set eligibleStockIds) { RepositoryPolicy policy = repository.loadPolicy(warehouseId); WarehouseSummary summary = repository.loadSummary(warehouseId); OutboundRequest effectiveRequest = request.fifoToleranceMinutes() == null && policy.fifoToleranceMinutes() > 0 @@ -187,18 +205,28 @@ public final class WarehouseApplicationService { matches.addAll(repository.searchOutboundStock(warehouseId, OutboundStockSearch.project(request.projectNo(), request.productId()))); } + if (eligibleStockIds != null) { + matches.removeIf(match -> !eligibleStockIds.contains(match.stockId())); + Set 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 matchedSpaceIds = matches.stream().map(OutboundStockMatch::spaceId) + .filter(this::nonBlank).collect(java.util.stream.Collectors.toSet()); List laneCodes = matches.stream().map(OutboundStockMatch::laneCode).filter(Objects::nonNull) .distinct().sorted().toList(); - List lanes = repository.loadLaneSnapshots(warehouseId, laneCodes); + List lanes = eligibleStockIds == null ? repository.loadLaneSnapshots(warehouseId, laneCodes) + : repository.loadShadowOutboundLaneSnapshots(warehouseId, laneCodes, matchedSpaceIds); Set floors = matches.stream().map(OutboundStockMatch::floorNo) .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); List buffers = repository.loadRelocationBuffers(warehouseId, floors); Map uniqueSpaces = new LinkedHashMap<>(); - Set matchedSpaceIds = matches.stream().map(OutboundStockMatch::spaceId) - .filter(this::nonBlank).collect(java.util.stream.Collectors.toSet()); String productScope = matchedProductScope; 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)); buffers.stream().map(SpaceSnapshot::state).forEach(space -> uniqueSpaces.put(space.spaceId(), space)); Map rawInboundTimes = new LinkedHashMap<>(); @@ -210,7 +238,8 @@ public final class WarehouseApplicationService { OutboundPlan plan = outboundPlanner.plan(effectiveRequest, new OutboundContext(List.copyOf(uniqueSpaces.values()), rawInboundTimes, capacityMode(summary, policy), - repository.loadActiveDeepWorkflowFloors(warehouseId, request.requestId()))); + repository.loadActiveDeepWorkflowFloors(warehouseId, request.requestId())), + eligibleStockIds == null ? null : matchedSpaceIds); String resultCode = switch (plan.status()) { case READY -> "READY"; case WAITING -> "WAITING_" + plan.waiting().orElseThrow().code().name(); @@ -312,10 +341,14 @@ public final class WarehouseApplicationService { } 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(), space.laneCapacity(), space.floorNo(), space.aisleNo(), space.groupCoding(), space.rowCode(), 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()); } diff --git a/fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/JdbcWarehouseRepositoryTest.java b/fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/JdbcWarehouseRepositoryTest.java index 49620eb..08875cd 100644 --- a/fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/JdbcWarehouseRepositoryTest.java +++ b/fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/JdbcWarehouseRepositoryTest.java @@ -104,6 +104,33 @@ class JdbcWarehouseRepositoryTest { 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 void statusFourPhysicalOuterPalletIsPreservedAndMakesDeepOutboundWait() throws Exception { execute("INSERT INTO BAS_CARGO_SPACE VALUES ('ALLOC-TARGET','W1','RA','A','Target',7,21,2,'2',''," diff --git a/fortucky-algorithm/src/test/java/com/ruoyi/algorithm/service/ShadowCandidatePlanningTest.java b/fortucky-algorithm/src/test/java/com/ruoyi/algorithm/service/ShadowCandidatePlanningTest.java new file mode 100644 index 0000000..d400752 --- /dev/null +++ b/fortucky-algorithm/src/test/java/com/ruoyi/algorithm/service/ShadowCandidatePlanningTest.java @@ -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); + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/Dzp/DzpSend.java b/ruoyi-admin/src/main/java/com/ruoyi/Dzp/DzpSend.java index 916ae55..bbfd07e 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/Dzp/DzpSend.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/Dzp/DzpSend.java @@ -1,6 +1,5 @@ package com.ruoyi.Dzp; - import com.ruoyi.Dzp.domain.basLed; import com.ruoyi.Dzp.mapper.BasLedMapper; 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 java.io.IOException; -import java.util.List; +import java.util.concurrent.locks.ReentrantLock; @RestController @RequestMapping("/api2dzp") @@ -17,104 +16,46 @@ public class DzpSend { @Autowired 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) - public void InLed() throws IOException { - basLed basLed = new basLed(); - basLed.setSendFlag("0"); - basLed.setId(1); - List 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 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(); - - } - - - } - + public void InLed() throws IOException { sendPending(false); } @Scheduled(fixedDelay = 1000) - public void ErrorLed() throws IOException { - basLed basLed = new basLed(); - basLed.setSendFlag("0"); - basLed.setId(1); - List 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(); + public void ErrorLed() throws IOException { sendPending(true); } - } - basLed basLed2 = new basLed(); - basLed2.setSendFlag("0"); - basLed2.setId(2); - List basLeds2 = mapper.selectBasLedList(basLed2); - if (basLeds2 != null && basLeds2.size() > 0 && basLeds2.get(0).getModel().equals("3")) { - MyUdpSocket myUdpSocket = new MyUdpSocket(6666); - ProtocolDemo.my_udp = myUdpSocket; - String ip = basLeds2.get(0).getIpAddress(); - if (ProtocolDemo.demo_play_chapterschedule2(ip, "报警信息", basLeds2.get(0).getErrorMsg())) { - basLeds2.get(0).setSendFlag("1"); - mapper.updateBasLed(basLeds2.get(0)); + private void sendPending(boolean alarm) throws IOException { + if (!portOwner.tryLock()) return; + try { + for (int id = 1; id <= 2; id++) { + basLed query = new basLed(); + query.setId(id); + query.setSendFlag("0"); + var records = mapper.selectBasLedList(query); + if (records == null || records.isEmpty()) continue; + basLed record = records.get(0); + boolean matchingModel = alarm ? "3".equals(record.getModel()) + : "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); } } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/Dzp/MyUdpSocket.java b/ruoyi-admin/src/main/java/com/ruoyi/Dzp/MyUdpSocket.java index 76527fb..8bb71e1 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/Dzp/MyUdpSocket.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/Dzp/MyUdpSocket.java @@ -6,88 +6,73 @@ import java.nio.ByteBuffer; import java.nio.channels.DatagramChannel; import java.nio.channels.SelectionKey; import java.nio.channels.Selector; -import java.util.Set; -public class MyUdpSocket { - private DatagramChannel channel; - private Selector selector; - private final int MAX_SIZE = 1280; - private static int client_port; - private static String client_ip; - ByteBuffer receive_buffer = ByteBuffer.allocate(MAX_SIZE); +/** One exchange owns its channel, selector, receive buffer and peer address. */ +public class MyUdpSocket implements AutoCloseable { + private final DatagramChannel channel; + private final Selector selector; + private int clientPort; + private String clientIp; + private final ByteBuffer receiveBuffer = ByteBuffer.allocate(1280); public MyUdpSocket(int port) throws IOException { channel = DatagramChannel.open(); - selector = Selector.open(); + Selector opened = null; try { - //������ͨ��Ϊ������ģʽ + opened = Selector.open(); channel.configureBlocking(false); - //��ȡ���׽���ͨ���������׽��֣��������׽��ְ󶨵�����ָ���˿� - channel.socket().bind(new InetSocketAddress(port)); - //Ϊͨ��ѡ����ע��ͨ������ָ��������ѡ����� - channel.register(selector, SelectionKey.OP_READ); - } catch (IOException e) { - e.printStackTrace(); + channel.bind(new InetSocketAddress(port)); + channel.register(opened, SelectionKey.OP_READ); + selector = opened; + } catch (IOException | RuntimeException failure) { + if (opened != null) { + 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 { - InetSocketAddress client = new InetSocketAddress(ip, port); - channel.send(ByteBuffer.wrap(buffer, 0, size), client); //��EchoClient�ظ�һ�����ݱ� + channel.send(ByteBuffer.wrap(buffer, 0, size), new InetSocketAddress(ip, port)); } + @Override public final void close() throws IOException { - selector.close(); - channel.close(); + try { selector.close(); } finally { channel.close(); } } - public final int get_client_port() { - return client_port; - } - - public final String get_client_ip() { - return client_ip; - } + public final int get_client_port() { return clientPort; } + public final String get_client_ip() { return clientIp; } public final void get_receive_buffer(byte[] buffer) { - receive_buffer.get(buffer); + get_receive_packet(buffer, buffer.length); } public final int receive() { try { - receive_buffer.clear(); + receiveBuffer.clear(); if (selector.select(10) != 0) { - Set readyKeys = selector.selectedKeys(); - for (SelectionKey key : readyKeys) { - readyKeys.remove(key); - if (key.isReadable()) { - DatagramChannel dc = (DatagramChannel) key.channel(); - InetSocketAddress client = (InetSocketAddress) dc.receive(receive_buffer); //������������һ��Client�����ݱ� - if (client != null) { - client_ip = client.getAddress().getHostAddress(); - client_port = client.getPort(); - //System.out.println(client_ip+":"+client_port+" size="+receive_buffer.position()); - key.interestOps(SelectionKey.OP_READ); - return receive_buffer.position(); - } else { - return 0; - } - } else { - return 0; + var keys = selector.selectedKeys().iterator(); + while (keys.hasNext()) { + SelectionKey key = keys.next(); + keys.remove(); + if (!key.isValid() || !key.isReadable()) continue; + InetSocketAddress peer = (InetSocketAddress) channel.receive(receiveBuffer); + if (peer != null) { + clientIp = peer.getAddress().getHostAddress(); + clientPort = peer.getPort(); + return receiveBuffer.position(); } } } + return 0; } 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) { - int i; - for (i = 0; i < size; i++) { - packet[i] = receive_buffer.array()[i]; - } + System.arraycopy(receiveBuffer.array(), 0, packet, 0, size); } - } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/Dzp/ProtocolDemo.java b/ruoyi-admin/src/main/java/com/ruoyi/Dzp/ProtocolDemo.java index 0d91377..e08b6f7 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/Dzp/ProtocolDemo.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/Dzp/ProtocolDemo.java @@ -6,6 +6,7 @@ import java.awt.*; //import java.util.Calendar; public class ProtocolDemo { + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(ProtocolDemo.class); // ====���붯����ʽ�б�(��ֵ��0��ʼ)==== // 0 = '���', @@ -101,10 +102,19 @@ public class ProtocolDemo { //����UDP����bufferΪ�������ݣ�sizeΪ�������ݳ��� 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; - 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) { - my_udp.get_receive_packet(buffer, size); + socket.get_receive_packet(buffer, size); } return size; } @@ -415,6 +425,20 @@ public class ProtocolDemo { } 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; byte[] packet = new byte[1280]; int i, j, k, x, r; @@ -435,9 +459,9 @@ public class ProtocolDemo { ok = false; for (tx = 0; tx < tx_repeat; tx++) { 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++) { - size = udp_receive(packet); + size = udp_receive(socket, packet); if (size > 0 && ledsender.parse_trans_respond(packet, size, LEDSender2010.PKC_BEGIN, 0) > 0) { ok = true; break; @@ -459,9 +483,9 @@ public class ProtocolDemo { for (tx = 0; tx < tx_repeat; tx++) { j = i; 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++) { - size = udp_receive(packet); + size = udp_receive(socket, packet); if (size > 0) { r = ledsender.parse_trans_respond(packet, size,LEDSender2010.PKC_DATA, i); switch (r) { @@ -490,9 +514,9 @@ public class ProtocolDemo { ok = false; for (tx = 0; tx < tx_repeat; tx++) { 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++) { - size = udp_receive(packet); + size = udp_receive(socket, packet); if (size > 0 && ledsender.parse_trans_respond(packet, size, LEDSender2010.PKC_END, i) > 0) { ok = true; break; @@ -870,6 +894,11 @@ public class ProtocolDemo { //�Զ��巽�� ��̬��ʾ������Ϣ 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(); //���ɽ�Ŀ���� ledsender.MakeRoot(LEDSender2010.ROOT_PLAY,LEDSender2010.COLOR_TYPE_DOUBLE); @@ -882,7 +911,7 @@ public class ProtocolDemo { ledsender.AddText(0, 11, 192, 32, msg1, "����", 16, Color.BLACK, Color.YELLOW, 0, 2, false, 1, 0, 2, 0, 0, 5, 10000); ledsender.AddText(0, 37, 192, 32, msg2, "����", 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); } //�Զ��巽����ʾ�������� @@ -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) { + 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(); //���ɽ�Ŀ���� ledsender.MakeRoot(LEDSender2010.ROOT_PLAY,LEDSender2010.COLOR_TYPE_DOUBLE); @@ -951,7 +985,7 @@ public class ProtocolDemo { ledsender.AddText(65, 60, 192, 21, msg8, "����", 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); } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowCandidateEvidence.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowCandidateEvidence.java new file mode 100644 index 0000000..32b1c66 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowCandidateEvidence.java @@ -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 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 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) { } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java index c6c5efb..f55372c 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java @@ -99,6 +99,13 @@ public class ShadowPlanningEngine { } List candidates = inputMapper.selectProjectOutboundCandidates( warehouseId, snapshot.generation(), stockoutDetailId); + return withCandidateEvidence(planProjectCandidates(snapshot, stockoutDetailId, reference, candidates), candidates); + }); + } + + private OutboundShadowObservation planProjectCandidates(PublishedShadowSnapshot snapshot, String stockoutDetailId, + ShadowStockReference reference, List candidates) { + String warehouseId = snapshot.warehouseId(); if (candidates.size() > MAX_OUTBOUND_CANDIDATES) { return unavailableProject(snapshot, stockoutDetailId, reference, "SKIPPED_CANDIDATE_LIMIT", "Eligible candidate set exceeds 200 rows"); @@ -117,16 +124,33 @@ public class ShadowPlanningEngine { 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. + List idle = candidates.stream() + .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 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, 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()) { return toOutboundObservation(snapshot, stockoutDetailId, reference, plan); } 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.getTrayNo(), assignment.trayNo()) && Objects.equals(candidate.getProjectNo(), assignment.projectNo()) @@ -140,10 +164,6 @@ public class ShadowPlanningEngine { 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, @@ -157,7 +177,14 @@ public class ShadowPlanningEngine { return new OutboundShadowObservation(true, snapshot.generation(), warehouseId, snapshot.synchronizedAt(), snapshot.sourceSignature(), stockoutDetailId, "READY", stock.getStockId(), stock.getSpaceId(), reason.toString()); - }); + } + + private OutboundShadowObservation withCandidateEvidence(OutboundShadowObservation observation, + List 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, @@ -195,8 +222,7 @@ public class ShadowPlanningEngine { UUID.randomUUID().toString(), observation.warehouseId(), "INBOUND_COMPARE", observation.snapshotGeneration(), observation.snapshotSynchronizedAt(), observation.sourceSignature(), - observation.observed() && "READY".equals(observation.resultCode()) && "10".equals(legacyResultCode) - ? comparisonCode(observation.proposedSpaceId(), legacySpaceId) : "NOT_COMPARABLE", + inboundComparisonCode(observation, legacyResultCode, legacySpaceId), inboundComparisonReason(observation, legacyResultCode, legacySpaceId)); requireComparisonInserted(inserted); }); @@ -244,22 +270,29 @@ public class ShadowPlanningEngine { return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_MISSING_TASK_DETAILS", "Job was captured but has no task details"); } - if (!Integer.valueOf(1).equals(reference.getFieldGroupCount())) { - return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_AMBIGUOUS_LEGACY_FIELDS", - "Legacy TOP (1) has no ordering and task details have different spare1/2/3 values"); - } - 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 (reference.getFieldGroupCount() == null || reference.getFieldGroupCount() < 1 + || reference.getProductScopeCount() == null || reference.getProductScopeCount() < 1) { + return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_MISSING_PREDICATE_INPUT", + "Task field or product scope counts are missing"); } if (isBlank(reference.getProjectNo()) || "999".equals(reference.getProjectNo().trim()) || "COMMON".equalsIgnoreCase(reference.getProjectNo().trim())) { return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_UNSUPPORTED_COMMON_PROJECT", "Common-project mapping and pool policy require separate validation"); } + boolean ambiguousFields = reference.getFieldGroupCount() > 1; + boolean multipleProducts = reference.getProductScopeCount() > 1; + List 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(), - reference.getProjectNo(), reference.getProductId(), reference.getLotNo(), - null, null, null, Set.of(), null); + reference.getProjectNo(), multipleProducts ? null : reference.getProductId(), + multipleProducts ? null : reference.getLotNo(), + null, null, null, Set.of(), null, Set.copyOf(eligibleSpaces)); InboundPlanningResult result = schedulingFacade.planInboundFromPublishedSnapshot(warehouseId, request); InboundPlan plan = result.plan(); if (plan.assignment().isPresent()) { @@ -272,9 +305,11 @@ public class ShadowPlanningEngine { } return new InboundShadowObservation(true, snapshot.generation(), warehouseId, snapshot.synchronizedAt(), snapshot.sourceSignature(), - reference.getRequestId(), "READY", assignment.spaceId(), + reference.getRequestId(), ambiguousFields ? "READY_CONSERVATIVE" : "READY", assignment.spaceId(), 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(); return InboundShadowObservation.unavailable(snapshot, @@ -555,6 +590,16 @@ public class ShadowPlanningEngine { 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) { return value == null || value.isBlank(); } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java index 799d34d..91b6975 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java @@ -19,6 +19,10 @@ public interface ShadowInputMapper { @Param("generation") long generation, @Param("taskNo") String taskNo, @Param("trayNo") String trayNo, @Param("spaceId") String spaceId); + List selectInboundEligibleSpaces(@Param("warehouseId") String warehouseId, + @Param("generation") long generation, @Param("taskNo") String taskNo, + @Param("trayNo") String trayNo); + ShadowStockReference selectProjectOutboundReference(@Param("warehouseId") String warehouseId, @Param("generation") long generation, @Param("stockoutDetailId") String stockoutDetailId); diff --git a/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml b/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml index 684ab42..14a0b46 100644 --- a/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml +++ b/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml @@ -32,6 +32,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + from alg.shadow_inbound_request_snapshot input join alg.cargo_space_snapshot cargo 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} and input.task_no = #{taskNo} and input.tray_no = #{trayNo} and input.predicate_version = 1 and input.detail_count > 0 - and input.field_group_count = 1 and input.product_scope_count = 1 + and input.field_group_count > 0 and input.product_scope_count > 0 and input.tray_job_count = 1 and input.legacy_scope_complete = 1 and nullif(ltrim(rtrim(input.project_no)), '') is not null 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 cargo.this_cargo_area in ('A', 'C') 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 where stock.warehouse_id = lane.warehouse_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 join alg.inventory_item_snapshot stock 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.this_cargo_area in ('A', 'C') 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))) + item.warehouse_id = #{warehouseId} @@ -130,6 +141,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 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.spare1, item.spare2, item.spare3, item.voltage, item.phase, item.tray_job_count as trayJobCount, item.quantity - item.out_quantity as availableQuantity, (select count(*) from alg.inventory_item_snapshot trayItem diff --git a/ruoyi-admin/src/test/java/com/ruoyi/Dzp/DzpSendTest.java b/ruoyi-admin/src/test/java/com/ruoyi/Dzp/DzpSendTest.java new file mode 100644 index 0000000..4fd95e3 --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/Dzp/DzpSendTest.java @@ -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(); + } +} diff --git a/ruoyi-admin/src/test/java/com/ruoyi/Dzp/MyUdpSocketTest.java b/ruoyi-admin/src/test/java/com/ruoyi/Dzp/MyUdpSocketTest.java new file mode 100644 index 0000000..5711bc1 --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/Dzp/MyUdpSocketTest.java @@ -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); + } +} diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowInboundEligibilityTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowInboundEligibilityTest.java index 98e3fd4..610e584 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowInboundEligibilityTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowInboundEligibilityTest.java @@ -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) { jdbc.update("insert into alg.cargo_space_snapshot values ('01',42,?,?,?,?,?,?,?,'UNRELATED')", id, lane, floor, status, locked, tray, area); diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowInboundValidationTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowInboundValidationTest.java index b8dec1d..5bb2bc2 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowInboundValidationTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowInboundValidationTest.java @@ -61,6 +61,7 @@ class ShadowInboundValidationTest { reference.setTrayJobCount(1); reference.setLegacyScopeComplete(1); 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); var assignment = new InboundAssignment("HPR-1", "SPACE-9", "LANE", 1, 1, InboundLadderRung.CONTINUE, InboundBorrowType.NONE, null, @@ -87,13 +88,22 @@ class ShadowInboundValidationTest { reference.setDetailCount(45); reference.setFieldGroupCount(2); 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 void multiProductTrayDoesNotSilentlyUseOneRepresentativeProduct() { 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 @@ -148,6 +158,12 @@ class ShadowInboundValidationTest { 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) { var result = plan(); assertThat(result.resultCode()).isEqualTo(code); diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowOutboundValidationTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowOutboundValidationTest.java index def665a..17a7140 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowOutboundValidationTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowOutboundValidationTest.java @@ -59,7 +59,7 @@ class ShadowOutboundValidationTest { candidate.setTrayJobCount(0); when(mapper.selectProjectOutboundReference("01", 459L, "DETAIL")).thenReturn(input); 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", "SPACE", 1, false, List.of(), List.of(), false, 0, 0, 0, FifoDecision.off(), Optional.empty(), "proposal"), TaskCountEstimate.zero())); @@ -71,7 +71,7 @@ class ShadowOutboundValidationTest { assertThat(result.resultCode()).isEqualTo("READY"); assertThat(result.proposedStockId()).isEqualTo("STOCK"); 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(facade, never()).planOutbound(anyString(), any()); } @@ -80,7 +80,7 @@ class ShadowOutboundValidationTest { void emptyProjectDerivesOnlyProvenCandidateScope() { input.setProjectNo(null); 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 @@ -134,9 +134,12 @@ class ShadowOutboundValidationTest { } @Test - void multipleRowsOnSelectedTrayAreExplicitlyUnsupported() { + void otherRowsOnSelectedTrayDoNotEraseTheEligibleItem() { 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 @@ -155,4 +158,13 @@ class ShadowOutboundValidationTest { private OutboundShadowObservation plan() { 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); + } } diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java index fcc2c39..dac75a9 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java @@ -52,6 +52,7 @@ class ShadowPlanningEngineTest { ShadowInboundReference input = inboundReference("1"); input.setShadowCandidateCount(3); 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.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT)); when(facade.planInboundFromPublishedSnapshot(eq("01"), any())) @@ -89,7 +90,7 @@ class ShadowPlanningEngineTest { .thenReturn(java.util.List.of(stockReference("01"))); when(mapper.selectProjectOutboundReference("01", 42L, "DETAIL-1")).thenReturn(reference); 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())); ShadowPlanningEngine engine = engine(facade, mapper); @@ -104,7 +105,7 @@ class ShadowPlanningEngineTest { "sourceSignature=ABC123", "detail=DETAIL-1", "rawWarehouse=W02", "project=PROJECT-1", "product=PRODUCT-1", "shadowMatches=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()); } @@ -359,6 +360,8 @@ class ShadowPlanningEngineTest { } 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()); } diff --git a/ruoyi-admin/src/test/java/com/ruoyi/in/stockindetail/controller/InStockinDetailBindingTest.java b/ruoyi-admin/src/test/java/com/ruoyi/in/stockindetail/controller/InStockinDetailBindingTest.java new file mode 100644 index 0000000..41202c6 --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/in/stockindetail/controller/InStockinDetailBindingTest.java @@ -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); + } +} diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/web/exception/GlobalExceptionHandler.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/web/exception/GlobalExceptionHandler.java index 1ca0283..84af4b7 100644 --- a/ruoyi-framework/src/main/java/com/ruoyi/framework/web/exception/GlobalExceptionHandler.java +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/web/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ package com.ruoyi.framework.web.exception; import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.InvalidPropertyException; import org.springframework.security.access.AccessDeniedException; import org.springframework.validation.BindException; 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)); } + @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, + "请求参数格式不正确,单号等普通字段请传单个值,不要传对象或数组"); + } + /** * 拦截未知的运行时异常 */