5 Commits

46 changed files with 2755 additions and 199 deletions

View File

@@ -0,0 +1,80 @@
# FT546WMS 入库影子 V3.4
这是影子采集与比较修复,不是正式算法切换。现场继续由 `dbo.proc_allot_space` 分配货位。
## 修复范围
- 入库输入从 `mydo_job` 和 `mydo_job_detail` 采集,不再依赖入库单是否仍在活动表。
- 影子请求标识采用 `job_id`,项目采用任务 `line_id`;采集明细 `spare1/2/3` 和任务 `fault_flag`。
- 保存明细总数、不同分组字段组合数、不同产品/批次组合数、重复任务数量,不用第一条明细代表整个托盘。
- 货位快照新增真正的 `this_cargo_area`,不把 `cargo_area_id` 当成 A/B/C/D 库区。
- 新输入标记 `predicate_version=1`;决策原因标记 `inboundPredicateVersion=1`。旧样本不能重新认定为新版本通过。
- 共享规划器、业务调用入口、Legacy 存储过程和出库影子规则不变。
## 比较口径与限制
新算法仍按原优化策略提出建议。只有建议通过保守的 Legacy 可选范围核验,且 Legacy 返回成功码 `10`,才可能记为 `MATCH` 或 `DIFFERENT`。
目前可核验的是单产品/批次、分组字段一致的项目入库:候选空闲、未锁定、无托盘/库存、A/C 区、楼层无入库作业、通道无入出库作业或锁定,并且整条通道为空,或通道内有托盘一致且项目及 spare1/2/3 全匹配的库存。
这是 `STRICT_GROUP_OR_EMPTY` 保守子集,不声称完整复刻 Legacy 排序。Legacy 的项目数量分支有按 `group_coding` 分组后赋值的歧义;严格分组匹配在两个分支内均属可选范围。`fault_flag=0` 的 C 区优先属于旧排序偏好,保留字段供分析,不替换共享规划器排序。
Legacy 的部分货位约束和重复任务判断不限定仓库,因此刷新前后的货位、任务及任务明细签名覆盖全局。其他仓库变动也可能使本轮影子快照失效;存在快照之外的货位时,入库只记录跳过。此限制有意保留,不能靠删除检查来扩大通过率。现场验收还需测量全局读取增加的开销。
以下情况记录具体原因,不能算通过:
| 原因码 | 含义 |
|---|---|
| `SKIPPED_MISSING_PREDICATE_INPUT` | 旧快照或字段超过 Legacy 参数长度 |
| `SKIPPED_MISSING_TASK_DETAILS` | 采到了任务,但没有任务明细 |
| `SKIPPED_AMBIGUOUS_LEGACY_FIELDS` | 明细 spare1/2/3 不一致,无法复现 Legacy 无排序的 TOP (1) |
| `SKIPPED_MULTI_PRODUCT_INPUT` | 单产品请求无法表示整个多产品/批次托盘,或缺少产品 |
| `SKIPPED_AMBIGUOUS_TRAY_JOB` | 同托盘或任务号存在多个任务 |
| `SKIPPED_INCOMPLETE_LEGACY_SCOPE` | 存在快照之外的仓库货位,不能用局部快照核验全局 Legacy 约束 |
| `SKIPPED_UNSUPPORTED_COMMON_PROJECT` | 空项目、999 或 COMMON 的通用件映射及单深池策略尚需单独验证 |
| `SKIPPED_LEGACY_CONSTRAINT` | 建议不在保守可选范围内;原因保留 `unverifiedProposal` |
受控混放、借池和放宽分组等优化建议可能落在此范围外。这不等于建议必然错误,但不能在尚未确认这些规则的情况下当作兼容性通过。
2026-09-07 的任务 31331 / 31333 分别有 45 / 35 条明细且 spare1 有不同值。修复后这类任务不再因缺少入库单被漏采,但仍应标为明细歧义,而不是强行改成通过。需要另行确定多明细业务规则后才能扩大验证范围。
## 部署准备
1. 先确认交付的提交号和已验证的 JAR,不要使用尚未推送的本地修改,也不要把带开发配置的原始 `target/ruoyi-admin.jar` 直接用于现场。
2. 沿用现场离线 JAR 加外部配置方式,保留现有 JAR、外部配置和 IDEA 启动项用于回退。新 JAR 应按既有交付流程剥离内置连接配置并核对 SHA-256。
3. 在隔离 SQL Server 上演练升级、重复执行和失败回滚。本地 H2 投影/Mapper 测试及 SQL 2016 解析不能代替真实 SQL Server 升级测试。
4. 记录升级前的数据库包版本、影子指标、启动时间和业务基线。本包只从 `2026-09-07-33` 升至 `2026-09-07-34`,增加 11 个可空列,保留影子历史日志。
## 维护窗口操作
停止 WMS 的影子启用实例,确认退出并等待至少 130 秒。不要停止 WCS 或 WCS 影子程序,不要同时运行两个 WMS。
在服务器已确认的新版本源码目录,用 CMD 执行:
```cmd
call scripts\shadow-v3-install.cmd "lpc:." "FT546-wms" v34
```
必须看到 `V34_UPGRADE_OK`(重复执行为 `V34_ALREADY_INSTALLED`)以及 `INSTALL_OK mode=v34`。
已有 V3.3 不要用 `clean`、不要先卸载,也不要改清单绕过心跳检查。失败则停止后续部署并保存输出。
`clean`/`upgrade` 模式会依次安装历史基础包、V3.3、V3.4,每个阶段单独提交;中间失败不代表之前阶段已回滚。`v34` 单步升级为一个事务。
启动前再执行只读结构检查,必须返回 `V34_SCHEMA_OK`:
```cmd
sqlcmd -S "lpc:." -d "FT546-wms" -E -b -r1 -i "scripts\shadow-v34-schema-check.sql"
```
该检查核对 V3.4 对象、继承列及新增列的类型、长度、精度和可空性,并检查刷新过程的版本标记。没有成功标记就不要继续。原 `shadow-v3-preflight.cmd` 也包含这项检查,但它还要求新鲜的已发布快照,不适合直接用在升级完成但尚未启动的 `INITIALIZING` 状态。
确认数据库完成后,再启动新 JAR,仅保留 `FORTUCKY_SHADOW_ENABLED=true`、`FORTUCKY_ALGORITHM_MODE=SHADOW`,不启用 ACTIVE。现有连接、内存、限流和日志配置不随意覆盖。
## 启动后检查与回滚
检查包版本为 `2026-09-07-34`,新快照为 `PUBLISHED`。通过自然业务观察入库,不制造生产测试单,不回放已完成任务。快照在事件触发时更新,没有新任务时输入表为空不等于故障。
继续用现有 `shadow-v3-export.cmd` 导出,提供准确升级时间、提交、JAR 哈希和日志;只分析含 `inboundPredicateVersion=1` 的升级后记录。累计性能指标用升级前后差值评估,关注后台读取增加的开销。
有异常时:停止新 WMS,将影子关闭,使用保留的原 JAR/源码及外部配置启动一个 WMS。无需卸载数据库,新列不参与 Legacy。回到旧代码也保持影子关闭,避免继续采集旧口径样本。
本次代码修复不代表具备正式切换条件。所有跳过均需单独统计,不能用连续运行天数代替支持范围、样本覆盖和性能验收。

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

@@ -2,7 +2,7 @@ SET NOCOUNT ON;
SET XACT_ABORT ON; SET XACT_ABORT ON;
IF SCHEMA_ID(N'alg') IS NULL THROW 51010, 'FT546 shadow uninstall refused: alg schema does not exist', 1; IF SCHEMA_ID(N'alg') IS NULL THROW 51010, 'FT546 shadow uninstall refused: alg schema does not exist', 1;
IF OBJECT_ID(N'alg.shadow_package_manifest', N'U') IS NULL THROW 51011, 'FT546 shadow uninstall refused: shadow package manifest does not match', 1; IF OBJECT_ID(N'alg.shadow_package_manifest', N'U') IS NULL THROW 51011, 'FT546 shadow uninstall refused: shadow package manifest does not match', 1;
IF (SELECT COUNT(*) FROM alg.shadow_package_manifest WHERE package_id='FT546WMS_SHADOW_ONLY' AND package_version IN ('2026-09-03-3','2026-09-07-33'))<>1 OR (SELECT COUNT(*) FROM alg.shadow_package_manifest)<>1 THROW 51012, 'FT546 shadow uninstall refused: shadow package manifest does not match', 1; IF (SELECT COUNT(*) FROM alg.shadow_package_manifest WHERE package_id='FT546WMS_SHADOW_ONLY' AND package_version IN ('2026-09-03-3','2026-09-07-33','2026-09-07-34'))<>1 OR (SELECT COUNT(*) FROM alg.shadow_package_manifest)<>1 THROW 51012, 'FT546 shadow uninstall refused: shadow package manifest does not match', 1;
DECLARE @expected_objects TABLE (object_name sysname NOT NULL PRIMARY KEY, object_type char(2) NOT NULL); DECLARE @expected_objects TABLE (object_name sysname NOT NULL PRIMARY KEY, object_type char(2) NOT NULL);
INSERT INTO @expected_objects VALUES INSERT INTO @expected_objects VALUES
(N'shadow_package_manifest','U'),(N'warehouse_policy','U'),(N'project_policy','U'),(N'space_role','U'),(N'space_topology','U'),(N'inventory_item_snapshot','U'),(N'space_inventory_summary','U'),(N'lane_state','U'),(N'reservation','U'),(N'deep_workflow_lock','U'),(N'decision_log','U'),(N'shadow_snapshot_state','U'),(N'cargo_space_snapshot','U'),(N'active_job_snapshot','U'),(N'shadow_inbound_request_snapshot','U'),(N'shadow_outbound_request_snapshot','U'),(N'shadow_runtime_metric','U'),(N'shadow_instance_heartbeat','U'), (N'shadow_package_manifest','U'),(N'warehouse_policy','U'),(N'project_policy','U'),(N'space_role','U'),(N'space_topology','U'),(N'inventory_item_snapshot','U'),(N'space_inventory_summary','U'),(N'lane_state','U'),(N'reservation','U'),(N'deep_workflow_lock','U'),(N'decision_log','U'),(N'shadow_snapshot_state','U'),(N'cargo_space_snapshot','U'),(N'active_job_snapshot','U'),(N'shadow_inbound_request_snapshot','U'),(N'shadow_outbound_request_snapshot','U'),(N'shadow_runtime_metric','U'),(N'shadow_instance_heartbeat','U'),

View File

@@ -0,0 +1,531 @@
/*
FT546WMS shadow V3.3 -> V3.4, SQL Server 2016 RTM compatible.
Run only after stopping the WMS shadow-enabled JVM and waiting >120 seconds.
Changes only alg.*. Never executes or alters a Legacy procedure.
One transaction: any failure rolls back columns, procedure, state and manifest.
*/
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET LOCK_TIMEOUT 5000;
SET DEADLOCK_PRIORITY LOW;
IF DB_NAME() <> N'FT546-wms'
THROW 51060, 'V3.4 upgrade refused: expected FT546-wms database', 1;
IF SCHEMA_ID(N'alg') IS NULL OR OBJECT_ID(N'alg.shadow_package_manifest', N'U') IS NULL
THROW 51061, 'V3.4 upgrade refused: shadow package manifest is missing', 1;
DECLARE @expected TABLE (object_name sysname PRIMARY KEY, object_type char(2) NOT NULL);
INSERT INTO @expected VALUES
(N'shadow_package_manifest','U'),(N'warehouse_policy','U'),(N'project_policy','U'),
(N'space_role','U'),(N'space_topology','U'),(N'inventory_item_snapshot','U'),
(N'space_inventory_summary','U'),(N'lane_state','U'),(N'reservation','U'),
(N'deep_workflow_lock','U'),(N'decision_log','U'),(N'shadow_snapshot_state','U'),
(N'cargo_space_snapshot','U'),(N'active_job_snapshot','U'),
(N'shadow_inbound_request_snapshot','U'),(N'shadow_outbound_request_snapshot','U'),
(N'shadow_runtime_metric','U'),(N'shadow_instance_heartbeat','U'),
(N'v_inventory_item','V'),(N'v_active_job','V'),(N'v_space_state','V'),
(N'usp_rebuild_space_topology','P'),(N'usp_sync_inventory_snapshot','P'),
(N'usp_compute_lane_semantic_fingerprint','P'),(N'usp_rebuild_lane_state','P'),
(N'usp_refresh_dirty_lanes','P'),(N'usp_claim_shadow_instance','P'),
(N'usp_refresh_shadow_snapshot','P');
DECLARE @columns TABLE (
table_name sysname, column_name sysname, type_name sysname, max_length smallint,
precision_value tinyint, scale_value tinyint, PRIMARY KEY (table_name, column_name));
INSERT INTO @columns VALUES
(N'cargo_space_snapshot',N'this_cargo_area',N'varchar',255,0,0),
(N'shadow_inbound_request_snapshot',N'spare1',N'varchar',255,0,0),
(N'shadow_inbound_request_snapshot',N'spare2',N'varchar',255,0,0),
(N'shadow_inbound_request_snapshot',N'spare3',N'varchar',255,0,0),
(N'shadow_inbound_request_snapshot',N'fault_flag',N'varchar',255,0,0),
(N'shadow_inbound_request_snapshot',N'detail_count',N'int',4,10,0),
(N'shadow_inbound_request_snapshot',N'field_group_count',N'int',4,10,0),
(N'shadow_inbound_request_snapshot',N'product_scope_count',N'int',4,10,0),
(N'shadow_inbound_request_snapshot',N'tray_job_count',N'int',4,10,0),
(N'shadow_inbound_request_snapshot',N'legacy_scope_complete',N'int',4,10,0),
(N'shadow_inbound_request_snapshot',N'predicate_version',N'int',4,10,0);
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @version varchar(40);
SELECT @version = package_version
FROM alg.shadow_package_manifest WITH (UPDLOCK, HOLDLOCK)
WHERE package_id = 'FT546WMS_SHADOW_ONLY';
IF @version IS NULL OR @version NOT IN ('2026-09-07-33', '2026-09-07-34')
OR (SELECT COUNT(*) FROM alg.shadow_package_manifest) <> 1
THROW 51062, 'V3.4 upgrade refused: exact shadow manifest does not match', 1;
IF EXISTS (
SELECT 1 FROM sys.objects object
LEFT JOIN @expected expected ON expected.object_name = object.name
AND expected.object_type = object.type COLLATE DATABASE_DEFAULT
WHERE object.schema_id = SCHEMA_ID(N'alg') AND object.is_ms_shipped = 0
AND object.parent_object_id = 0 AND expected.object_name IS NULL)
THROW 51063, 'V3.4 upgrade refused: unknown object exists in alg schema', 1;
IF EXISTS (
SELECT 1 FROM @expected expected
LEFT JOIN sys.objects object ON object.schema_id = SCHEMA_ID(N'alg')
AND object.name = expected.object_name
AND object.type COLLATE DATABASE_DEFAULT = expected.object_type
WHERE object.object_id IS NULL)
THROW 51064, 'V3.4 upgrade refused: expected object is missing or has wrong type', 1;
IF EXISTS (SELECT 1 FROM sys.triggers trigger_object
JOIN sys.objects parent_object ON parent_object.object_id = trigger_object.parent_id
WHERE parent_object.schema_id = SCHEMA_ID(N'alg'))
THROW 51065, 'V3.4 upgrade refused: unexpected trigger on shadow table', 1;
IF EXISTS (SELECT 1 FROM alg.shadow_instance_heartbeat WITH (UPDLOCK, HOLDLOCK)
WHERE last_seen_at >= DATEADD(SECOND, -120, SYSUTCDATETIME())
OR claimed_at >= DATEADD(SECOND, -120, SYSUTCDATETIME()))
THROW 51066, 'V3.4 upgrade refused: recent shadow heartbeat; stop WMS and wait over 120 seconds', 1;
IF @version = '2026-09-07-34'
BEGIN
IF EXISTS (
SELECT 1 FROM @columns expected
LEFT JOIN sys.columns column_info
ON column_info.object_id = OBJECT_ID(N'alg.' + expected.table_name)
AND column_info.name = expected.column_name
WHERE column_info.column_id IS NULL
OR TYPE_NAME(column_info.user_type_id) <> expected.type_name
OR column_info.max_length <> expected.max_length
OR column_info.precision <> expected.precision_value
OR column_info.scale <> expected.scale_value
OR column_info.is_nullable <> 1)
THROW 51067, 'V3.4 upgrade refused: target columns are missing or incompatible', 1;
DECLARE @installed_definition nvarchar(max) = OBJECT_DEFINITION(OBJECT_ID(N'alg.usp_refresh_shadow_snapshot', N'P'));
IF @installed_definition IS NULL OR CHARINDEX(N'INBOUND_JOB_FIELDS_V1', @installed_definition) = 0
OR CHARINDEX(N'field_group_count', @installed_definition) = 0
OR CHARINDEX(N'this_cargo_area', @installed_definition) = 0
THROW 51071, 'V3.4 upgrade refused: refresh procedure is missing, unreadable or obsolete', 1;
COMMIT TRANSACTION;
PRINT 'V34_ALREADY_INSTALLED';
RETURN;
END;
IF EXISTS (
SELECT 1 FROM @columns expected JOIN sys.columns column_info
ON column_info.object_id = OBJECT_ID(N'alg.' + expected.table_name)
AND column_info.name = expected.column_name)
THROW 51068, 'V3.4 upgrade refused: partial or foreign predicate columns already exist', 1;
IF COL_LENGTH(N'alg.inventory_item_snapshot', N'spare1') IS NULL
OR COL_LENGTH(N'alg.shadow_outbound_request_snapshot', N'predicate_version') IS NULL
THROW 51070, 'V3.4 upgrade refused: V3.3 predicate columns are missing', 1;
ALTER TABLE alg.cargo_space_snapshot ADD this_cargo_area varchar(255) NULL;
ALTER TABLE alg.shadow_inbound_request_snapshot ADD
spare1 varchar(255) NULL, spare2 varchar(255) NULL, spare3 varchar(255) NULL,
fault_flag varchar(255) NULL, detail_count int NULL, field_group_count int NULL,
product_scope_count int NULL, tray_job_count int NULL, legacy_scope_complete int NULL,
predicate_version int NULL;
DECLARE @refresh_sql nvarchar(max) = N'ALTER PROCEDURE alg.usp_refresh_shadow_snapshot
@warehouse_id varchar(80),
@snapshot_generation bigint,
@instance_id varchar(80)
AS
BEGIN
-- INBOUND_JOB_FIELDS_V1: task-derived inbound fields and conservative eligibility evidence.
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF NULLIF(LTRIM(RTRIM(@warehouse_id)), '''') IS NULL
THROW 51006, ''warehouse_id is required'', 1;
IF NULLIF(LTRIM(RTRIM(@instance_id)), '''') IS NULL
THROW 51007, ''instance_id is required'', 1;
IF @snapshot_generation <= 0
THROW 51008, ''snapshot_generation must be positive'', 1;
DECLARE @pre_source_count bigint, @post_source_count bigint,
@pre_source_signature varbinary(32), @post_source_signature varbinary(32),
@source_payload nvarchar(max), @synchronized_at datetime2,
@numeric_warehouse_id decimal(38,0),
@current_generation bigint;
IF LTRIM(RTRIM(@warehouse_id)) NOT LIKE ''%[^0-9]%''
SET @numeric_warehouse_id = TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(@warehouse_id)));
BEGIN TRY
BEGIN TRANSACTION;
SELECT @current_generation = state.snapshot_generation
FROM alg.shadow_snapshot_state state WITH (UPDLOCK, HOLDLOCK)
WHERE state.warehouse_id = @warehouse_id;
IF @current_generation IS NOT NULL AND @snapshot_generation <= @current_generation
THROW 51009, ''snapshot_generation must advance the published generation'', 1;
SELECT @pre_source_count =
(SELECT COUNT_BIG(*) FROM dbo.bas_cargo_space cargo WITH (NOLOCK))
+ (SELECT COUNT_BIG(*) FROM dbo.bas_stock stock WITH (NOLOCK)
JOIN dbo.bas_cargo_space cargo WITH (NOLOCK) ON cargo.cargo_space_id = stock.cargo_space_id
WHERE cargo.warehouse_id = @warehouse_id)
+ (SELECT COUNT_BIG(*) FROM dbo.mydo_job job WITH (NOLOCK))
+ (SELECT COUNT_BIG(*) FROM dbo.mydo_job_detail detail WITH (NOLOCK)
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id)
+ (SELECT COUNT_BIG(*) FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
JOIN dbo.out_stockout stockout WITH (NOLOCK) ON stockout.stockout_id = outbound.stockout_id
WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)
AND (LTRIM(RTRIM(stockout.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
OR (@numeric_warehouse_id IS NOT NULL
AND LTRIM(RTRIM(stockout.warehouse_id)) NOT LIKE ''%[^0-9]%''
AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockout.warehouse_id))) = @numeric_warehouse_id)));
SELECT @source_payload = ISNULL(CONVERT(nvarchar(max), (
SELECT cargo.cargo_space_id AS [@space_id], cargo.warehouse_id AS [@warehouse_id],
cargo.group_coding_no AS [@lane_code], cargo.cargo_area_id AS [@cargo_area_id],
cargo.roadway_id AS [@roadway_id], cargo.cargo_space_name AS [@display_name],
cargo.the_platoon AS [@platoon], cargo.the_column AS [@column_no],
cargo.the_floor AS [@floor_no], cargo.group_coding AS [@depth_raw],
cargo.status AS [@space_status], cargo.locked AS [@locked], cargo.tray_code AS [@tray_code],
cargo.this_cargo_area AS [@this_cargo_area]
FROM dbo.bas_cargo_space cargo WITH (NOLOCK)
ORDER BY cargo.cargo_space_id
FOR XML PATH(''cargo''), ROOT(''cargo_spaces''), TYPE
)), '''');
SET @source_payload = @source_payload + ''|STOCK|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT stock.stock_id AS [@stock_id], stock.cargo_space_id AS [@space_id],
stock.warehouse_id AS [@warehouse_id], stock.tray_code AS [@tray_code],
stock.spare4 AS [@project_no], stock.product_id AS [@product_no],
stock.lot_no AS [@lot_no], stock.in_date AS [@in_date],
stock.quantity AS [@quantity], stock.out_quantity AS [@out_quantity],
stock.spare1 AS [@spare1], stock.spare2 AS [@spare2], stock.spare3 AS [@spare3],
stock.voltage AS [@voltage], stock.phase AS [@phase],
(SELECT COUNT(*) FROM dbo.mydo_job trayJob WITH (NOLOCK)
WHERE trayJob.tray_code = stock.tray_code) AS [@tray_job_count]
FROM dbo.bas_stock stock WITH (NOLOCK)
JOIN dbo.bas_cargo_space cargo WITH (NOLOCK) ON cargo.cargo_space_id = stock.cargo_space_id
WHERE cargo.warehouse_id = @warehouse_id
ORDER BY stock.stock_id
FOR XML PATH(''stock''), ROOT(''stocks''), TYPE
)), '''');
SET @source_payload = @source_payload + ''|JOB|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT job.job_id AS [@job_id], job.status AS [@status], job.task_no AS [@task_no],
job.tray_code AS [@tray_code], job.cargo_space_id AS [@source_space_id],
job.target_cargo_space_id AS [@target_space_id], job.in_or_out AS [@in_or_out],
job.create_time AS [@create_time], job.finish_time AS [@finish_time],
job.warehouse_id AS [@warehouse_id], job.line_id AS [@line_id], job.fault_flag AS [@fault_flag]
FROM dbo.mydo_job job WITH (NOLOCK)
ORDER BY job.job_id
FOR XML PATH(''job''), ROOT(''jobs''), TYPE
)), '''');
SET @source_payload = @source_payload + ''|JOB_DETAIL|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT detail.job_detail_id AS [@job_detail_id], detail.job_id AS [@job_id],
detail.stock_id AS [@stock_id], detail.lot_no AS [@lot_no],
detail.in_date AS [@in_date], detail.in_or_out_id AS [@request_id],
detail.in_or_out_d_id AS [@request_detail_id], detail.product_id AS [@product_id],
detail.stock_status AS [@stock_status], detail.in_quantity AS [@in_quantity],
detail.stock_quantity AS [@stock_quantity], detail.out_quantity AS [@out_quantity],
detail.pick_quantity AS [@pick_quantity], detail.stockin_detail_id AS [@stockin_detail_id],
detail.spare1 AS [@spare1], detail.spare2 AS [@spare2], detail.spare3 AS [@spare3]
FROM dbo.mydo_job_detail detail WITH (NOLOCK)
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
ORDER BY detail.job_detail_id
FOR XML PATH(''job_detail''), ROOT(''job_details''), TYPE
)), '''');
SET @source_payload = @source_payload + ''|OUTBOUND|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT outbound.stockout_detail_id AS [@stockout_detail_id], outbound.warehouse_id AS [@warehouse_id],
stockout.warehouse_id AS [@source_warehouse_id],
outbound.spare4 AS [@project_no],
outbound.spare1 AS [@spare1], outbound.spare2 AS [@spare2], outbound.spare3 AS [@spare3],
outbound.fid AS [@voltage], outbound.unit AS [@phase],
outbound.product_id AS [@product_id], outbound.lot_no AS [@lot_no],
outbound.quantity AS [@quantity], outbound.assign_num AS [@assign_num],
outbound.allot_quantity AS [@allot_quantity]
FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
JOIN dbo.out_stockout stockout WITH (NOLOCK) ON stockout.stockout_id = outbound.stockout_id
WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)
AND (LTRIM(RTRIM(stockout.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
OR (@numeric_warehouse_id IS NOT NULL
AND LTRIM(RTRIM(stockout.warehouse_id)) NOT LIKE ''%[^0-9]%''
AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockout.warehouse_id))) = @numeric_warehouse_id))
ORDER BY outbound.stockout_detail_id
FOR XML PATH(''outbound''), ROOT(''outbound_requests''), TYPE
)), '''');
SET @pre_source_signature = HASHBYTES(''SHA2_256'', CONVERT(varbinary(max), @source_payload));
DELETE FROM alg.cargo_space_snapshot WHERE warehouse_id = @warehouse_id;
DELETE FROM alg.inventory_item_snapshot WHERE warehouse_id = @warehouse_id;
DELETE FROM alg.active_job_snapshot WHERE warehouse_id = @warehouse_id;
DELETE FROM alg.shadow_inbound_request_snapshot WHERE warehouse_id = @warehouse_id;
DELETE FROM alg.shadow_outbound_request_snapshot WHERE warehouse_id = @warehouse_id;
DELETE FROM alg.space_topology WHERE warehouse_id = @warehouse_id;
DELETE FROM alg.space_inventory_summary WHERE warehouse_id = @warehouse_id;
DELETE FROM alg.lane_state WHERE warehouse_id = @warehouse_id;
SET @synchronized_at = SYSUTCDATETIME();
INSERT INTO alg.cargo_space_snapshot (
warehouse_id, space_id, lane_code, cargo_area_id, roadway_id, display_name,
platoon, column_no, floor_no, depth_raw, space_status, locked, tray_code,
snapshot_generation, synchronized_at, this_cargo_area)
SELECT cargo.warehouse_id, cargo.cargo_space_id, cargo.group_coding_no,
cargo.cargo_area_id, cargo.roadway_id, cargo.cargo_space_name,
cargo.the_platoon, cargo.the_column, cargo.the_floor, cargo.group_coding,
cargo.status, cargo.locked, cargo.tray_code,
@snapshot_generation, @synchronized_at, cargo.this_cargo_area
FROM dbo.bas_cargo_space cargo WITH (NOLOCK)
WHERE cargo.warehouse_id = @warehouse_id;
INSERT INTO alg.inventory_item_snapshot (
warehouse_id, stock_id, space_id, tray_code, project_no, product_no, lot_no,
inbound_time_raw, inbound_at, quantity, out_quantity, snapshot_generation, synchronized_at,
spare1, spare2, spare3, voltage, phase, tray_job_count)
SELECT cargo.warehouse_id, stock.stock_id, stock.cargo_space_id, stock.tray_code,
stock.spare4, stock.product_id, stock.lot_no, stock.in_date,
TRY_CONVERT(datetime2, stock.in_date), stock.quantity, stock.out_quantity,
@snapshot_generation, @synchronized_at,
stock.spare1, stock.spare2, stock.spare3, stock.voltage, stock.phase,
(SELECT COUNT(*) FROM dbo.mydo_job trayJob WITH (NOLOCK)
WHERE trayJob.tray_code = stock.tray_code)
FROM dbo.bas_stock stock WITH (NOLOCK)
JOIN dbo.bas_cargo_space cargo WITH (NOLOCK) ON cargo.cargo_space_id = stock.cargo_space_id
WHERE cargo.warehouse_id = @warehouse_id;
INSERT INTO alg.active_job_snapshot (
warehouse_id, job_id, task_no, tray_code, source_space_id, target_space_id,
status, in_or_out, create_time_raw, snapshot_generation, synchronized_at)
SELECT job.warehouse_id, job.job_id, job.task_no, job.tray_code,
NULLIF(LTRIM(RTRIM(job.cargo_space_id)), ''''),
NULLIF(LTRIM(RTRIM(job.target_cargo_space_id)), ''''), job.status,
job.in_or_out, job.create_time, @snapshot_generation, @synchronized_at
FROM dbo.mydo_job job WITH (NOLOCK)
WHERE job.warehouse_id = @warehouse_id
AND (job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '''');
;WITH inbound_detail_groups AS (
SELECT detail.job_id, detail.spare1, detail.spare2, detail.spare3
FROM dbo.mydo_job_detail detail WITH (NOLOCK)
GROUP BY detail.job_id, detail.spare1, detail.spare2, detail.spare3
), inbound_product_scopes AS (
SELECT detail.job_id, detail.product_id, detail.lot_no
FROM dbo.mydo_job_detail detail WITH (NOLOCK)
GROUP BY detail.job_id, detail.product_id, detail.lot_no
), inbound_details AS (
SELECT detail.job_id, COUNT(*) AS detail_count,
MIN(detail.spare1) AS spare1, MIN(detail.spare2) AS spare2, MIN(detail.spare3) AS spare3,
MIN(detail.product_id) AS product_id, MIN(detail.lot_no) AS lot_no,
MAX(ISNULL(DATALENGTH(detail.spare1), 0)) AS spare1_bytes,
MAX(ISNULL(DATALENGTH(detail.spare2), 0)) AS spare2_bytes,
MAX(ISNULL(DATALENGTH(detail.spare3), 0)) AS spare3_bytes
FROM dbo.mydo_job_detail detail WITH (NOLOCK)
GROUP BY detail.job_id
), inbound_source AS (
SELECT job.task_no, job.tray_code, job.job_id AS request_id,
job.warehouse_id AS raw_warehouse_id, job.line_id AS project_no, job.fault_flag,
detail.product_id, detail.lot_no, detail.spare1, detail.spare2, detail.spare3,
ISNULL(detail.detail_count, 0) AS detail_count,
(SELECT COUNT(*) FROM inbound_detail_groups groups WHERE groups.job_id = job.job_id) AS field_group_count,
(SELECT COUNT(*) FROM inbound_product_scopes scopes WHERE scopes.job_id = job.job_id) AS product_scope_count,
(SELECT COUNT(*) FROM dbo.mydo_job trayJob WITH (NOLOCK)
WHERE trayJob.tray_code = job.tray_code OR trayJob.task_no = job.task_no) AS tray_job_count,
CASE WHEN EXISTS (SELECT 1 FROM dbo.bas_cargo_space scopeCargo WITH (NOLOCK)
WHERE scopeCargo.warehouse_id IS NULL OR scopeCargo.warehouse_id <> @warehouse_id)
THEN 0 ELSE 1 END AS legacy_scope_complete,
CASE WHEN ISNULL(DATALENGTH(job.line_id), 0) <= 80
AND ISNULL(DATALENGTH(job.fault_flag), 0) <= 5
AND ISNULL(detail.spare1_bytes, 0) <= 80
AND ISNULL(detail.spare2_bytes, 0) <= 80
AND ISNULL(detail.spare3_bytes, 0) <= 80 THEN 1 ELSE 0 END AS predicate_version,
ROW_NUMBER() OVER (
PARTITION BY job.task_no, job.tray_code
ORDER BY job.job_id) AS source_rank
FROM dbo.mydo_job job WITH (NOLOCK)
LEFT JOIN inbound_details detail ON detail.job_id = job.job_id
WHERE job.in_or_out < 20
AND (job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '''')
AND (LTRIM(RTRIM(job.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
OR (@numeric_warehouse_id IS NOT NULL
AND LTRIM(RTRIM(job.warehouse_id)) NOT LIKE ''%[^0-9]%''
AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(job.warehouse_id))) = @numeric_warehouse_id))
)
INSERT INTO alg.shadow_inbound_request_snapshot (
warehouse_id, task_no, tray_no, request_id, raw_warehouse_id,
project_no, product_id, lot_no, snapshot_generation, synchronized_at,
spare1, spare2, spare3, fault_flag, detail_count, field_group_count,
product_scope_count, tray_job_count, legacy_scope_complete, predicate_version)
SELECT @warehouse_id, ISNULL(task_no, ''''), ISNULL(tray_code, ''''), request_id,
raw_warehouse_id, project_no, product_id, lot_no,
@snapshot_generation, @synchronized_at,
spare1, spare2, spare3, fault_flag, detail_count, field_group_count,
product_scope_count, tray_job_count, legacy_scope_complete, predicate_version
FROM inbound_source
WHERE source_rank = 1;
INSERT INTO alg.shadow_outbound_request_snapshot (
warehouse_id, stockout_detail_id, raw_warehouse_id, project_no, product_id,
requested_quantity, snapshot_generation, synchronized_at,
allocated_quantity, spare1, spare2, spare3, voltage, phase, predicate_version)
SELECT @warehouse_id, outbound.stockout_detail_id, outbound.warehouse_id,
outbound.spare4, outbound.product_id,
CONVERT(decimal(38,6), outbound.quantity),
@snapshot_generation, @synchronized_at,
CONVERT(decimal(38,6), outbound.allot_quantity),
outbound.spare1, outbound.spare2, outbound.spare3, outbound.fid, outbound.unit,
CASE WHEN ISNULL(DATALENGTH(outbound.product_id), 0) <= 80
AND ISNULL(DATALENGTH(outbound.spare1), 0) <= 80
AND ISNULL(DATALENGTH(outbound.spare2), 0) <= 80
AND ISNULL(DATALENGTH(outbound.spare3), 0) <= 80
AND ISNULL(DATALENGTH(outbound.spare4), 0) <= 80
AND ISNULL(DATALENGTH(outbound.fid), 0) <= 80
AND ISNULL(DATALENGTH(outbound.unit), 0) <= 80
THEN 1 ELSE 0 END
FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
JOIN dbo.out_stockout stockout WITH (NOLOCK) ON stockout.stockout_id = outbound.stockout_id
WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)
AND (LTRIM(RTRIM(stockout.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
OR (@numeric_warehouse_id IS NOT NULL
AND LTRIM(RTRIM(stockout.warehouse_id)) NOT LIKE ''%[^0-9]%''
AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockout.warehouse_id))) = @numeric_warehouse_id));
EXEC alg.usp_rebuild_space_topology @warehouse_id = @warehouse_id,
@snapshot_generation = @snapshot_generation;
EXEC alg.usp_sync_inventory_snapshot @warehouse_id = @warehouse_id,
@snapshot_generation = @snapshot_generation;
EXEC alg.usp_rebuild_lane_state @warehouse_id = @warehouse_id,
@snapshot_generation = @snapshot_generation;
SELECT @post_source_count =
(SELECT COUNT_BIG(*) FROM dbo.bas_cargo_space cargo WITH (NOLOCK))
+ (SELECT COUNT_BIG(*) FROM dbo.bas_stock stock WITH (NOLOCK)
JOIN dbo.bas_cargo_space cargo WITH (NOLOCK) ON cargo.cargo_space_id = stock.cargo_space_id
WHERE cargo.warehouse_id = @warehouse_id)
+ (SELECT COUNT_BIG(*) FROM dbo.mydo_job job WITH (NOLOCK))
+ (SELECT COUNT_BIG(*) FROM dbo.mydo_job_detail detail WITH (NOLOCK)
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id)
+ (SELECT COUNT_BIG(*) FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
JOIN dbo.out_stockout stockout WITH (NOLOCK) ON stockout.stockout_id = outbound.stockout_id
WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)
AND (LTRIM(RTRIM(stockout.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
OR (@numeric_warehouse_id IS NOT NULL
AND LTRIM(RTRIM(stockout.warehouse_id)) NOT LIKE ''%[^0-9]%''
AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockout.warehouse_id))) = @numeric_warehouse_id)));
SELECT @source_payload = ISNULL(CONVERT(nvarchar(max), (
SELECT cargo.cargo_space_id AS [@space_id], cargo.warehouse_id AS [@warehouse_id],
cargo.group_coding_no AS [@lane_code], cargo.cargo_area_id AS [@cargo_area_id],
cargo.roadway_id AS [@roadway_id], cargo.cargo_space_name AS [@display_name],
cargo.the_platoon AS [@platoon], cargo.the_column AS [@column_no],
cargo.the_floor AS [@floor_no], cargo.group_coding AS [@depth_raw],
cargo.status AS [@space_status], cargo.locked AS [@locked], cargo.tray_code AS [@tray_code],
cargo.this_cargo_area AS [@this_cargo_area]
FROM dbo.bas_cargo_space cargo WITH (NOLOCK)
ORDER BY cargo.cargo_space_id
FOR XML PATH(''cargo''), ROOT(''cargo_spaces''), TYPE
)), '''');
SET @source_payload = @source_payload + ''|STOCK|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT stock.stock_id AS [@stock_id], stock.cargo_space_id AS [@space_id],
stock.warehouse_id AS [@warehouse_id], stock.tray_code AS [@tray_code],
stock.spare4 AS [@project_no], stock.product_id AS [@product_no],
stock.lot_no AS [@lot_no], stock.in_date AS [@in_date],
stock.quantity AS [@quantity], stock.out_quantity AS [@out_quantity],
stock.spare1 AS [@spare1], stock.spare2 AS [@spare2], stock.spare3 AS [@spare3],
stock.voltage AS [@voltage], stock.phase AS [@phase],
(SELECT COUNT(*) FROM dbo.mydo_job trayJob WITH (NOLOCK)
WHERE trayJob.tray_code = stock.tray_code) AS [@tray_job_count]
FROM dbo.bas_stock stock WITH (NOLOCK)
JOIN dbo.bas_cargo_space cargo WITH (NOLOCK) ON cargo.cargo_space_id = stock.cargo_space_id
WHERE cargo.warehouse_id = @warehouse_id
ORDER BY stock.stock_id
FOR XML PATH(''stock''), ROOT(''stocks''), TYPE
)), '''');
SET @source_payload = @source_payload + ''|JOB|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT job.job_id AS [@job_id], job.status AS [@status], job.task_no AS [@task_no],
job.tray_code AS [@tray_code], job.cargo_space_id AS [@source_space_id],
job.target_cargo_space_id AS [@target_space_id], job.in_or_out AS [@in_or_out],
job.create_time AS [@create_time], job.finish_time AS [@finish_time],
job.warehouse_id AS [@warehouse_id], job.line_id AS [@line_id], job.fault_flag AS [@fault_flag]
FROM dbo.mydo_job job WITH (NOLOCK)
ORDER BY job.job_id
FOR XML PATH(''job''), ROOT(''jobs''), TYPE
)), '''');
SET @source_payload = @source_payload + ''|JOB_DETAIL|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT detail.job_detail_id AS [@job_detail_id], detail.job_id AS [@job_id],
detail.stock_id AS [@stock_id], detail.lot_no AS [@lot_no],
detail.in_date AS [@in_date], detail.in_or_out_id AS [@request_id],
detail.in_or_out_d_id AS [@request_detail_id], detail.product_id AS [@product_id],
detail.stock_status AS [@stock_status], detail.in_quantity AS [@in_quantity],
detail.stock_quantity AS [@stock_quantity], detail.out_quantity AS [@out_quantity],
detail.pick_quantity AS [@pick_quantity], detail.stockin_detail_id AS [@stockin_detail_id],
detail.spare1 AS [@spare1], detail.spare2 AS [@spare2], detail.spare3 AS [@spare3]
FROM dbo.mydo_job_detail detail WITH (NOLOCK)
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
ORDER BY detail.job_detail_id
FOR XML PATH(''job_detail''), ROOT(''job_details''), TYPE
)), '''');
SET @source_payload = @source_payload + ''|OUTBOUND|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT outbound.stockout_detail_id AS [@stockout_detail_id], outbound.warehouse_id AS [@warehouse_id],
stockout.warehouse_id AS [@source_warehouse_id],
outbound.spare4 AS [@project_no],
outbound.spare1 AS [@spare1], outbound.spare2 AS [@spare2], outbound.spare3 AS [@spare3],
outbound.fid AS [@voltage], outbound.unit AS [@phase],
outbound.product_id AS [@product_id], outbound.lot_no AS [@lot_no],
outbound.quantity AS [@quantity], outbound.assign_num AS [@assign_num],
outbound.allot_quantity AS [@allot_quantity]
FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
JOIN dbo.out_stockout stockout WITH (NOLOCK) ON stockout.stockout_id = outbound.stockout_id
WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)
AND (LTRIM(RTRIM(stockout.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
OR (@numeric_warehouse_id IS NOT NULL
AND LTRIM(RTRIM(stockout.warehouse_id)) NOT LIKE ''%[^0-9]%''
AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockout.warehouse_id))) = @numeric_warehouse_id))
ORDER BY outbound.stockout_detail_id
FOR XML PATH(''outbound''), ROOT(''outbound_requests''), TYPE
)), '''');
SET @post_source_signature = HASHBYTES(''SHA2_256'', CONVERT(varbinary(max), @source_payload));
IF @pre_source_count <> @post_source_count
OR @pre_source_signature <> @post_source_signature
THROW 51025, ''FT546 shadow refresh refused: source changed during refresh'', 1;
MERGE alg.shadow_snapshot_state WITH (HOLDLOCK) AS target
USING (SELECT @warehouse_id AS warehouse_id) AS source
ON source.warehouse_id = target.warehouse_id
WHEN MATCHED THEN UPDATE SET
snapshot_generation = @snapshot_generation,
snapshot_status = ''PUBLISHED'',
synchronized_at = SYSUTCDATETIME(),
source_signature = @post_source_signature,
source_row_count = @post_source_count,
instance_id = @instance_id,
last_error = NULL
WHEN NOT MATCHED THEN INSERT (
warehouse_id, snapshot_generation, snapshot_status, synchronized_at,
source_signature, source_row_count, instance_id, last_error)
VALUES (
@warehouse_id, @snapshot_generation, ''PUBLISHED'', SYSUTCDATETIME(),
@post_source_signature, @post_source_count, @instance_id, NULL);
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0
ROLLBACK TRANSACTION;
THROW;
END CATCH;
END;';
EXEC sys.sp_executesql @refresh_sql;
-- Keep the generation monotonic; old rows cannot become predicate-version-1 evidence.
UPDATE alg.shadow_snapshot_state WITH (UPDLOCK, HOLDLOCK)
SET snapshot_status = 'INITIALIZING', source_signature = NULL, synchronized_at = NULL,
last_error = NULL;
UPDATE alg.shadow_package_manifest
SET package_version = '2026-09-07-34'
WHERE package_id = 'FT546WMS_SHADOW_ONLY' AND package_version = '2026-09-07-33';
IF @@ROWCOUNT <> 1
THROW 51069, 'V3.4 upgrade refused: manifest changed during upgrade', 1;
COMMIT TRANSACTION;
PRINT 'V34_UPGRADE_OK';
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
THROW;
END CATCH;
GO

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

@@ -8,6 +8,37 @@ public class ShadowInboundReference {
private String productId; private String productId;
private String lotNo; private String lotNo;
private Integer shadowCandidateCount; private Integer shadowCandidateCount;
private String spare1;
private String spare2;
private String spare3;
private String faultFlag;
private Integer detailCount;
private Integer fieldGroupCount;
private Integer productScopeCount;
private Integer trayJobCount;
private Integer legacyScopeComplete;
private Integer predicateVersion;
public String getSpare1() { return spare1; }
public void setSpare1(String value) { spare1 = value; }
public String getSpare2() { return spare2; }
public void setSpare2(String value) { spare2 = value; }
public String getSpare3() { return spare3; }
public void setSpare3(String value) { spare3 = value; }
public String getFaultFlag() { return faultFlag; }
public void setFaultFlag(String value) { faultFlag = value; }
public Integer getDetailCount() { return detailCount; }
public void setDetailCount(Integer value) { detailCount = value; }
public Integer getFieldGroupCount() { return fieldGroupCount; }
public void setFieldGroupCount(Integer value) { fieldGroupCount = value; }
public Integer getProductScopeCount() { return productScopeCount; }
public void setProductScopeCount(Integer value) { productScopeCount = value; }
public Integer getTrayJobCount() { return trayJobCount; }
public void setTrayJobCount(Integer value) { trayJobCount = value; }
public Integer getLegacyScopeComplete() { return legacyScopeComplete; }
public void setLegacyScopeComplete(Integer value) { legacyScopeComplete = value; }
public Integer getPredicateVersion() { return predicateVersion; }
public void setPredicateVersion(Integer value) { predicateVersion = value; }
public String getRequestId() { public String getRequestId() {
return requestId; return requestId;

View File

@@ -283,6 +283,17 @@ public class ShadowObservationGateway {
metrics.increment(ShadowSkipReason.CONCURRENT); metrics.increment(ShadowSkipReason.CONCURRENT);
} catch (Exception shadowFailure) { } catch (Exception shadowFailure) {
coordinator.failRefresh(ticket, failureDescription(failureStage, shadowFailure)); coordinator.failRefresh(ticket, failureDescription(failureStage, shadowFailure));
if (isSourceChanged(shadowFailure)) {
// SQL 51025 is the stored procedure's optimistic-concurrency guard:
// source rows changed while the snapshot was being built. Treat it as
// a stale refresh and defer the next attempt; retrying immediately only
// creates another collision and incorrectly reports an application error.
metrics.increment(ShadowSkipReason.STALE);
recoveryNotBefore.set(clock.instant().plus(RECOVERY_COOLDOWN));
LOG.info("shadow_refresh_stale warehouse={} snapshotGeneration={} sqlCode=51025",
warehouseId, generation);
return;
}
metrics.recordError(failureStage); metrics.recordError(failureStage);
warnFailure("CONTROL", generation, failureStage, shadowFailure, startedNanos); warnFailure("CONTROL", generation, failureStage, shadowFailure, startedNanos);
} }
@@ -290,6 +301,21 @@ public class ShadowObservationGateway {
recoveryNotBefore.set(clock.instant().plus(RECOVERY_COOLDOWN)); recoveryNotBefore.set(clock.instant().plus(RECOVERY_COOLDOWN));
} }
private boolean isSourceChanged(Throwable failure) {
Throwable cause = failure;
for (int depth = 0; depth < 8 && cause != null; depth++) {
if (cause instanceof SQLException sql && sql.getErrorCode() == 51025) {
return true;
}
Throwable next = cause.getCause();
if (next == cause) {
break;
}
cause = next;
}
return false;
}
private Optional<ShadowSnapshotCoordinator.RefreshTicket> tryBeginRefreshAfterQuietWindow() private Optional<ShadowSnapshotCoordinator.RefreshTicket> tryBeginRefreshAfterQuietWindow()
throws InterruptedException { throws InterruptedException {
if (coordinator.activeLegacyCount() != 0) { if (coordinator.activeLegacyCount() != 0) {

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,7 +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(),
comparisonCode(observation.proposedSpaceId(), legacySpaceId), inboundComparisonCode(observation, legacyResultCode, legacySpaceId),
inboundComparisonReason(observation, legacyResultCode, legacySpaceId)); inboundComparisonReason(observation, legacyResultCode, legacySpaceId));
requireComparisonInserted(inserted); requireComparisonInserted(inserted);
}); });
@@ -226,17 +253,63 @@ public class ShadowPlanningEngine {
inboundDiagnostic(snapshot, taskNo, trayNo, reference, inboundDiagnostic(snapshot, taskNo, trayNo, reference,
"Inbound task details were unavailable to shadow planning")); "Inbound task details were unavailable to shadow planning"));
} }
if (!Integer.valueOf(1).equals(reference.getPredicateVersion())) {
return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_MISSING_PREDICATE_INPUT",
"Task input is missing or predates inbound predicate version 1");
}
if (!WarehouseIdNormalizer.equivalent(reference.getWarehouseId(), warehouseId)
|| !Integer.valueOf(1).equals(reference.getLegacyScopeComplete())) {
return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_INCOMPLETE_LEGACY_SCOPE",
"Warehouse snapshot does not cover the global Legacy allocation scope");
}
if (!Integer.valueOf(1).equals(reference.getTrayJobCount())) {
return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_AMBIGUOUS_TRAY_JOB",
"Task or tray identifies more than one Legacy job");
}
if (reference.getDetailCount() == null || reference.getDetailCount() <= 0) {
return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_MISSING_TASK_DETAILS",
"Job was captured but has no task details");
}
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<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()) {
var assignment = plan.assignment().orElseThrow(); var assignment = plan.assignment().orElseThrow();
if (inputMapper.countInboundEligibleSpace(warehouseId, snapshot.generation(),
taskNo, trayNo, assignment.spaceId()) != 1) {
return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_LEGACY_CONSTRAINT",
"unverifiedProposal=" + abbreviate(nullSafe(assignment.spaceId()), IDENTIFIER_MAX_LENGTH)
+ "; outside conservative Legacy eligibility subset; " + assignment.reason());
}
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, assignment.reason())); inboundDiagnostic(snapshot, taskNo, trayNo, reference,
"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,
@@ -244,6 +317,12 @@ public class ShadowPlanningEngine {
inboundDiagnostic(snapshot, taskNo, trayNo, reference, rejection.reason())); inboundDiagnostic(snapshot, taskNo, trayNo, reference, rejection.reason()));
} }
private InboundShadowObservation unavailableInbound(PublishedShadowSnapshot snapshot, String taskNo,
String trayNo, ShadowInboundReference reference, String code, String reason) {
return InboundShadowObservation.unavailable(snapshot, reference.getRequestId(), code,
inboundDiagnostic(snapshot, taskNo, trayNo, reference, reason));
}
private OutboundShadowObservation toOutboundObservation(PublishedShadowSnapshot snapshot, private OutboundShadowObservation toOutboundObservation(PublishedShadowSnapshot snapshot,
String requestId, ShadowStockReference reference, OutboundPlan plan) { String requestId, ShadowStockReference reference, OutboundPlan plan) {
if (isBlank(requestId)) { if (isBlank(requestId)) {
@@ -409,6 +488,18 @@ public class ShadowPlanningEngine {
IDENTIFIER_MAX_LENGTH); IDENTIFIER_MAX_LENGTH);
appendField(diagnostic, "shadowCandidates", numberOrUnknown( appendField(diagnostic, "shadowCandidates", numberOrUnknown(
reference == null ? null : reference.getShadowCandidateCount()), NUMBER_MAX_LENGTH); reference == null ? null : reference.getShadowCandidateCount()), NUMBER_MAX_LENGTH);
if (reference != null) {
appendField(diagnostic, "inboundPredicateVersion", numberOrUnknown(reference.getPredicateVersion()), NUMBER_MAX_LENGTH);
appendField(diagnostic, "detailCount", numberOrUnknown(reference.getDetailCount()), NUMBER_MAX_LENGTH);
appendField(diagnostic, "fieldGroupCount", numberOrUnknown(reference.getFieldGroupCount()), NUMBER_MAX_LENGTH);
appendField(diagnostic, "productScopeCount", numberOrUnknown(reference.getProductScopeCount()), NUMBER_MAX_LENGTH);
appendField(diagnostic, "trayJobCount", numberOrUnknown(reference.getTrayJobCount()), NUMBER_MAX_LENGTH);
appendField(diagnostic, "legacyScopeComplete", numberOrUnknown(reference.getLegacyScopeComplete()), NUMBER_MAX_LENGTH);
appendField(diagnostic, "spare1", reference.getSpare1(), 120);
appendField(diagnostic, "spare2", reference.getSpare2(), 120);
appendField(diagnostic, "spare3", reference.getSpare3(), 120);
appendField(diagnostic, "faultFlag", reference.getFaultFlag(), 20);
}
appendField(diagnostic, "reason", resultReason, RESULT_REASON_MAX_LENGTH); appendField(diagnostic, "reason", resultReason, RESULT_REASON_MAX_LENGTH);
return diagnostic.toString(); return diagnostic.toString();
} }
@@ -499,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

@@ -15,6 +15,14 @@ public interface ShadowInputMapper {
@Param("generation") long generation, @Param("taskNo") String taskNo, @Param("generation") long generation, @Param("taskNo") String taskNo,
@Param("trayNo") String trayNo); @Param("trayNo") String trayNo);
int countInboundEligibleSpace(@Param("warehouseId") String warehouseId,
@Param("generation") long generation, @Param("taskNo") String taskNo,
@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

@@ -337,6 +337,9 @@ public class PdaController extends BaseController {
public AjaxResult OutFillPkDetail(@RequestBody OutStockout outStockout) { public AjaxResult OutFillPkDetail(@RequestBody OutStockout outStockout) {
List<OutStockout> outStockouts = outStockoutService.selectOutStockoutList(outStockout); List<OutStockout> outStockouts = outStockoutService.selectOutStockoutList(outStockout);
if (StringUtils.isEmpty(outStockouts)) {
return AjaxResult.error("未查询到匹配的出库单,请核对出库单号及查询条件后重试。");
}
return AjaxResult.success(outStockoutDetailService.selectOutStockoutDetailByStockoutGuid(outStockouts.get(0).getStockoutGuid())); return AjaxResult.success(outStockoutDetailService.selectOutStockoutDetailByStockoutGuid(outStockouts.get(0).getStockoutGuid()));
} }

View File

@@ -85,10 +85,20 @@ public class PdaServiceImp implements PdaService {
@Override @Override
public AjaxResult halfTrayOut(TrayCodeMsg trayCodeMsg) { public AjaxResult halfTrayOut(TrayCodeMsg trayCodeMsg) {
if (!"1".equals(trayCodeMsg.getExitId()) && !"2".equals(trayCodeMsg.getExitId())) {
return AjaxResult.error("请选择有效的出库出口(出口1或出口2)。");
}
InStockinDetail inStockinDetail = inStockinDetailService.selectInStockinDetailByStockinDetailId(trayCodeMsg.getStockinDetailId()); InStockinDetail inStockinDetail = inStockinDetailService.selectInStockinDetailByStockinDetailId(trayCodeMsg.getStockinDetailId());
// InStockin inStockin = inStockinService.selectInStockinByStockinId(inStockinDetail.getStockinId()); if (inStockinDetail == null) {
// inStockinDetail.setSpare4(inStockin.getDepartmentNo()); return AjaxResult.error("未查询到入库明细,请核对条码后重试。");
}
if (mapper.selectBySpare4OrCargo(inStockinDetail) > 0) {
return AjaxResult.error("该项目或匹配编码已有半盘出库作业,请完成后再操作。");
}
List<BasStock> hasHalfTray = basStockMapper.selectHasHalfTray(inStockinDetail); List<BasStock> hasHalfTray = basStockMapper.selectHasHalfTray(inStockinDetail);
if (CollUtil.isEmpty(hasHalfTray)) {
return AjaxResult.error("未找到可出库的未满托盘,请刷新后重试。");
}
HashMap<String, Object> map = new HashMap<>(); HashMap<String, Object> map = new HashMap<>();
map.put("v_tray_code", hasHalfTray.get(0).getTrayCode()); map.put("v_tray_code", hasHalfTray.get(0).getTrayCode());
map.put("v_exit_id", trayCodeMsg.getExitId()); map.put("v_exit_id", trayCodeMsg.getExitId());

View File

@@ -12,6 +12,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
input.project_no as projectNo, input.project_no as projectNo,
input.product_id as productId, input.product_id as productId,
input.lot_no as lotNo, input.lot_no as lotNo,
input.spare1, input.spare2, input.spare3, input.fault_flag as faultFlag,
input.detail_count as detailCount, input.field_group_count as fieldGroupCount,
input.product_scope_count as productScopeCount, input.tray_job_count as trayJobCount,
input.legacy_scope_complete as legacyScopeComplete, input.predicate_version as predicateVersion,
convert(int, coalesce(( convert(int, coalesce((
select sum(convert(bigint, lane.available_count)) select sum(convert(bigint, lane.available_count))
from alg.lane_state lane from alg.lane_state lane
@@ -25,6 +29,68 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and input.tray_no = #{trayNo} and input.tray_no = #{trayNo}
</select> </select>
<!-- Conservative subset valid in either Legacy project-count branch. Ranking remains in the planner. -->
<select id="countInboundEligibleSpace" resultType="int">
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
join alg.cargo_space_snapshot cargo
on cargo.warehouse_id = input.warehouse_id
and cargo.snapshot_generation = input.snapshot_generation
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 &gt; 0
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 nullif(ltrim(rtrim(input.project_no)), '') is not null
and input.project_no not in ('999', 'COMMON')
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
and nullif(ltrim(rtrim(cargo.floor_no)), '') is not null
and not exists (
select 1 from alg.inventory_item_snapshot occupied
where occupied.warehouse_id = cargo.warehouse_id
and occupied.snapshot_generation = cargo.snapshot_generation and occupied.space_id = cargo.space_id)
and not exists (
select 1 from alg.cargo_space_snapshot busy
where busy.warehouse_id = cargo.warehouse_id and busy.snapshot_generation = cargo.snapshot_generation
and ((busy.floor_no = cargo.floor_no and busy.space_status = '3')
or (busy.lane_code = cargo.lane_code and (busy.space_status in ('3', '4') or busy.locked = 'Y'))))
and (
not exists (
select 1 from alg.cargo_space_snapshot lane
where lane.warehouse_id = cargo.warehouse_id and lane.snapshot_generation = cargo.snapshot_generation
and lane.lane_code = cargo.lane_code
and (lane.space_status is null or lane.space_status &lt;&gt; '1'
or lane.locked is null or lane.locked &lt;&gt; 'N'
or nullif(ltrim(rtrim(lane.tray_code)), '') is not null
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 (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
and stock.space_id = resident.space_id and stock.tray_code = resident.tray_code
where resident.warehouse_id = cargo.warehouse_id and resident.snapshot_generation = cargo.snapshot_generation
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)))
</sql>
<sql id="outboundCandidatePredicate"> <sql id="outboundCandidatePredicate">
item.warehouse_id = #{warehouseId} item.warehouse_id = #{warehouseId}
and item.snapshot_generation = #{generation} and item.snapshot_generation = #{generation}
@@ -75,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

@@ -298,7 +298,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectBySpare4OrCargo" parameterType="InStockinDetail" resultType="java.lang.Integer"> <select id="selectBySpare4OrCargo" parameterType="InStockinDetail" resultType="java.lang.Integer">
select count(1) from mydo_job where job_id in (select job_id from mydo_job_detail where spare4=#{spare4} or exit_id =#{cargoNo}) and in_or_out='26' select count(1) from mydo_job
where in_or_out='26' and job_id in (
select job_id from mydo_job_detail
where 1=0
<if test="spare4 != null and spare4.trim() != ''">
or spare4=#{spare4}
</if>
<if test="cargoNo != null and cargoNo.trim() != ''">
or exit_id=#{cargoNo}
</if>
)
</select> </select>

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

@@ -0,0 +1,200 @@
package com.ruoyi.algorithm.shadow;
import com.ruoyi.algorithm.shadow.mapper.ShadowInputMapper;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class ShadowInboundEligibilityTest {
private JdbcTemplate jdbc;
private SqlSessionFactory sessions;
@BeforeEach
void prepare() throws Exception {
var dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MSSQLServer;DB_CLOSE_DELAY=-1");
jdbc = new JdbcTemplate(dataSource);
jdbc.execute("create schema alg");
jdbc.execute("""
create table alg.shadow_inbound_request_snapshot (
warehouse_id varchar(80), snapshot_generation bigint, task_no varchar(80), tray_no varchar(80),
request_id varchar(80), raw_warehouse_id varchar(80), product_id varchar(255), lot_no varchar(80),
project_no varchar(255), spare1 varchar(255), spare2 varchar(255), spare3 varchar(255),
fault_flag varchar(255), detail_count int, field_group_count int, product_scope_count int,
tray_job_count int, legacy_scope_complete int, predicate_version int)
""");
jdbc.execute("""
create table alg.cargo_space_snapshot (
warehouse_id varchar(80), snapshot_generation bigint, space_id varchar(80),
lane_code varchar(80), floor_no varchar(80), space_status varchar(20), locked varchar(20),
tray_code varchar(80), this_cargo_area varchar(255), cargo_area_id varchar(80))
""");
jdbc.execute("""
create table alg.inventory_item_snapshot (
warehouse_id varchar(80), snapshot_generation bigint, stock_id varchar(80), space_id varchar(80),
tray_code varchar(80), project_no varchar(255), spare1 varchar(255), spare2 varchar(255), spare3 varchar(255))
""");
jdbc.execute("create table alg.lane_state (warehouse_id varchar(80), snapshot_generation bigint, available_count int)");
jdbc.update("""
insert into alg.shadow_inbound_request_snapshot values
('01',42,'31331','TRAY','HPR-1','01','P1','LOT','PROJECT','201','','','0',1,1,1,1,1,1)
""");
space("TARGET", "LANE", "1", "1", "N", null, "C");
var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), dataSource));
try (var stream = getClass().getClassLoader().getResourceAsStream("mapper/algorithm/ShadowInputMapper.xml")) {
new XMLMapperBuilder(stream, configuration, "mapper/algorithm/ShadowInputMapper.xml",
configuration.getSqlFragments()).parse();
}
sessions = new SqlSessionFactoryBuilder().build(configuration);
}
@AfterEach
void close() { jdbc.execute("shutdown"); }
@Test
void emptyProjectLaneIsEligibleAndInputFieldsSurviveMapping() {
assertThat(eligible()).isEqualTo(1);
try (var session = sessions.openSession()) {
var input = session.getMapper(ShadowInputMapper.class).selectInboundInput("01", 42, "31331", "TRAY");
assertThat(input.getProjectNo()).isEqualTo("PROJECT");
assertThat(input.getSpare1()).isEqualTo("201");
assertThat(input.getFaultFlag()).isEqualTo("0");
assertThat(input.getFieldGroupCount()).isEqualTo(1);
assertThat(input.getPredicateVersion()).isEqualTo(1);
}
}
@Test
void candidateMustBeEmptyUnlockedAndPhysicallyVacant() {
for (String state : new String[]{"2", "3", "4"}) {
jdbc.update("update alg.cargo_space_snapshot set space_status=?", state);
assertThat(eligible()).isZero();
}
jdbc.update("update alg.cargo_space_snapshot set space_status='1',locked='Y'");
assertThat(eligible()).isZero();
jdbc.update("update alg.cargo_space_snapshot set locked='N',tray_code='ORPHAN'");
assertThat(eligible()).isZero();
jdbc.update("update alg.cargo_space_snapshot set tray_code=null");
stock("TARGET", "ORPHAN");
assertThat(eligible()).isZero();
}
@Test
void actualAreaIsNotCargoAreaId() {
jdbc.update("update alg.cargo_space_snapshot set cargo_area_id='C',this_cargo_area='B'");
assertThat(eligible()).isZero();
jdbc.update("update alg.cargo_space_snapshot set cargo_area_id='B',this_cargo_area='A'");
assertThat(eligible()).isEqualTo(1);
}
@Test
void inboundWorkElsewhereOnSameFloorBlocksProjectCandidate() {
space("BUSY", "OTHER", "1", "3", "N", null, "C");
assertThat(eligible()).isZero();
jdbc.update("update alg.cargo_space_snapshot set floor_no='2' where space_id='BUSY'");
assertThat(eligible()).isEqualTo(1);
}
@Test
void busyOrLockedLaneIsNotAnEmptyLane() {
space("BUSY", "LANE", "1", "4", "N", "RESIDENT", "C");
stock("BUSY", "RESIDENT");
assertThat(eligible()).isZero();
jdbc.update("update alg.cargo_space_snapshot set space_status='2',locked='Y' where space_id='BUSY'");
assertThat(eligible()).isZero();
}
@Test
void continuationRequiresRawSpareFieldsProjectAndTrayAlignment() {
space("RESIDENT", "LANE", "1", "2", "N", "OLD", "C");
stock("RESIDENT", "OLD");
assertThat(eligible()).isEqualTo(1);
for (String field : new String[]{"spare1", "spare2", "spare3", "project_no", "tray_code"}) {
String original = jdbc.queryForObject("select " + field + " from alg.inventory_item_snapshot", String.class);
jdbc.update("update alg.inventory_item_snapshot set " + field + "='WRONG'");
assertThat(eligible()).as(field).isZero();
jdbc.update("update alg.inventory_item_snapshot set " + field + "=?", original);
}
}
@Test
void nullSpareIsNotConvertedToEmptyForContinuation() {
space("RESIDENT", "LANE", "1", "2", "N", "OLD", "C");
stock("RESIDENT", "OLD");
jdbc.update("update alg.inventory_item_snapshot set spare2=null");
assertThat(eligible()).isZero();
}
@Test
void olderGenerationCannotSupplyCandidateOrResidentEvidence() {
jdbc.update("update alg.cargo_space_snapshot set snapshot_generation=41");
assertThat(eligible()).isZero();
jdbc.update("update alg.cargo_space_snapshot set snapshot_generation=42");
space("RESIDENT", "LANE", "1", "2", "N", "OLD", "C");
stock("RESIDENT", "OLD");
jdbc.update("update alg.inventory_item_snapshot set snapshot_generation=41");
assertThat(eligible()).isZero();
}
@Test
void ambiguousInputAndOldPredicateVersionNeverPassSqlGuard() {
for (String field : new String[]{"predicate_version", "field_group_count", "product_scope_count",
"tray_job_count", "legacy_scope_complete"}) {
jdbc.update("update alg.shadow_inbound_request_snapshot set " + field + "=0");
assertThat(eligible()).as(field).isZero();
jdbc.update("update alg.shadow_inbound_request_snapshot set " + field + "=1");
}
}
@Test
void unknownLaneOrFloorDoesNotPassValidation() {
jdbc.update("update alg.cargo_space_snapshot set lane_code=null");
assertThat(eligible()).isZero();
jdbc.update("update alg.cargo_space_snapshot set lane_code='LANE',floor_no=null");
assertThat(eligible()).isZero();
}
private int eligible() {
try (var session = sessions.openSession()) {
return session.getMapper(ShadowInputMapper.class).countInboundEligibleSpace("01", 42, "31331", "TRAY", "TARGET");
}
}
@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);
}
private void stock(String space, String tray) {
jdbc.update("insert into alg.inventory_item_snapshot values ('01',42,'STOCK',?,?,'PROJECT','201','','')", space, tray);
}
}

View File

@@ -0,0 +1,187 @@
package com.ruoyi.algorithm.shadow;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.jdbc.core.JdbcTemplate;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class ShadowInboundProjectionTest {
private JdbcTemplate jdbc;
@BeforeEach
void createFixture() {
JdbcDataSource dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MSSQLServer;DB_CLOSE_DELAY=-1");
jdbc = new JdbcTemplate(dataSource);
jdbc.execute("create schema dbo");
jdbc.execute("create schema alg");
jdbc.execute("""
create table dbo.mydo_job (job_id varchar(80), task_no varchar(80), tray_code varchar(80),
warehouse_id varchar(80), finish_time varchar(80), in_or_out int, line_id varchar(255),
fault_flag varchar(255), cargo_space_id varchar(80))
""");
jdbc.execute("""
create table dbo.mydo_job_detail (job_detail_id varchar(80), job_id varchar(80),
stockin_detail_id varchar(80), in_or_out_id varchar(80),
spare1 varchar(255), spare2 varchar(255), spare3 varchar(255),
product_id varchar(255), lot_no varchar(80))
""");
jdbc.execute("""
create table dbo.in_stockin_detail (stockin_detail_id varchar(80), stockin_id varchar(80),
spare4 varchar(255), product_id varchar(255), lot_no varchar(80))
""");
jdbc.execute("create table dbo.in_stockin (stockin_id varchar(80), warehouse_id varchar(80))");
jdbc.execute("create table dbo.bas_cargo_space (warehouse_id varchar(80))");
jdbc.update("insert into dbo.bas_cargo_space values ('01')");
jdbc.execute("""
create table alg.shadow_inbound_request_snapshot (
warehouse_id varchar(80), task_no varchar(80), tray_no varchar(255), request_id varchar(80),
raw_warehouse_id varchar(80), project_no varchar(255), product_id varchar(255), lot_no varchar(80),
snapshot_generation bigint, synchronized_at datetime2,
spare1 varchar(255), spare2 varchar(255), spare3 varchar(255), fault_flag varchar(255),
detail_count int, field_group_count int, product_scope_count int, tray_job_count int,
legacy_scope_complete int, predicate_version int)
""");
jdbc.update("insert into dbo.mydo_job values ('HPR-1','31331','L0000282','01',null,2,'PROJECT-JOB','0',null)");
detail("1", "201", "P1", null, "");
}
@AfterEach
void closeFixture() {
jdbc.execute("shutdown");
}
@Test
void taskWithoutInboundDocumentIsStillCapturedWithJobIdentityAndProject() throws Exception {
refreshInbound();
var rows = jdbc.queryForList("select * from alg.shadow_inbound_request_snapshot");
assertThat(rows).hasSize(1);
assertThat(rows.get(0)).containsEntry("REQUEST_ID", "HPR-1")
.containsEntry("PROJECT_NO", "PROJECT-JOB")
.containsEntry("SPARE1", "201").containsEntry("FAULT_FLAG", "0")
.containsEntry("DETAIL_COUNT", 1).containsEntry("PREDICATE_VERSION", 1);
}
@Test
void linkedDocumentDoesNotOverrideTheActualLegacyTaskFields() throws Exception {
jdbc.update("update dbo.mydo_job_detail set stockin_detail_id='RKD-1',in_or_out_id='RKD'");
jdbc.update("insert into dbo.in_stockin_detail values ('RKD-1','RKD','WRONG-PROJECT','WRONG-PRODUCT','WRONG-LOT')");
jdbc.update("insert into dbo.in_stockin values ('RKD','1')");
refreshInbound();
var input = jdbc.queryForMap("select * from alg.shadow_inbound_request_snapshot");
assertThat(input).containsEntry("PROJECT_NO", "PROJECT-JOB")
.containsEntry("PRODUCT_ID", "P1").containsEntry("LOT_NO", "LOT")
.containsEntry("REQUEST_ID", "HPR-1");
}
@Test
void mixedDetailFieldsAreCountedInsteadOfHiddenByFirstRowSelection() throws Exception {
detail("2", "", "P2", "ARCHIVED-DOCUMENT", null);
refreshInbound();
var input = jdbc.queryForMap("select * from alg.shadow_inbound_request_snapshot");
assertThat(input).containsEntry("DETAIL_COUNT", 2).containsEntry("FIELD_GROUP_COUNT", 2)
.containsEntry("PRODUCT_SCOPE_COUNT", 2);
}
@Test
void nullAndEmptyGroupingValuesRemainDifferent() throws Exception {
detail("2", null, "P1", null, null);
refreshInbound();
assertThat(jdbc.queryForObject("select field_group_count from alg.shadow_inbound_request_snapshot", Integer.class))
.isEqualTo(2);
}
@Test
void multipleProductsWithSameLegacyGroupingStillHaveExplicitScope() throws Exception {
detail("2", "201", "P2", null, null);
refreshInbound();
var input = jdbc.queryForMap("select * from alg.shadow_inbound_request_snapshot");
assertThat(input).containsEntry("DETAIL_COUNT", 2).containsEntry("FIELD_GROUP_COUNT", 1)
.containsEntry("PRODUCT_SCOPE_COUNT", 2);
}
@Test
void missingDetailsAreCapturedForDiagnosticsNotInvented() throws Exception {
jdbc.execute("delete from dbo.mydo_job_detail");
refreshInbound();
var input = jdbc.queryForMap("select * from alg.shadow_inbound_request_snapshot");
assertThat(input).containsEntry("DETAIL_COUNT", 0).containsEntry("FIELD_GROUP_COUNT", 0);
}
@Test
void completedOutboundAndOtherWarehouseJobsAreNotInboundInputs() throws Exception {
jdbc.update("insert into dbo.mydo_job values ('DONE','2','T2','01','2026-09-07',2,'P','0',null)");
jdbc.update("insert into dbo.mydo_job values ('OUT','3','T3','01',null,20,'P','0',null)");
jdbc.update("insert into dbo.mydo_job values ('OTHER','4','T4','02',null,2,'P','0',null)");
refreshInbound();
assertThat(jdbc.queryForList("select request_id from alg.shadow_inbound_request_snapshot", String.class))
.containsExactly("HPR-1");
}
@Test
void duplicateTrayInAnotherWarehouseCannotLookUnambiguous() throws Exception {
jdbc.update("insert into dbo.mydo_job values ('OTHER','4','L0000282','02',null,2,'P','0',null)");
refreshInbound();
assertThat(jdbc.queryForObject("select tray_job_count from alg.shadow_inbound_request_snapshot", Integer.class))
.isEqualTo(2);
}
@Test
void oversizedLegacyFieldIsPreservedButNotValidated() throws Exception {
jdbc.update("update dbo.mydo_job_detail set spare1=?", "X".repeat(81));
refreshInbound();
var input = jdbc.queryForMap("select * from alg.shadow_inbound_request_snapshot");
assertThat(input).containsEntry("SPARE1", "X".repeat(81)).containsEntry("PREDICATE_VERSION", 0);
}
@ParameterizedTest
@NullSource
@ValueSource(strings = {"02", "1"})
void cargoOutsideSnapshotScopeMakesGlobalLegacyValidationUnavailable(String warehouse) throws Exception {
jdbc.update("insert into dbo.bas_cargo_space values (?)", warehouse);
refreshInbound();
assertThat(jdbc.queryForObject("select legacy_scope_complete from alg.shadow_inbound_request_snapshot", Integer.class))
.isZero();
}
private void detail(String id, String spare1, String product, String link, String document) {
jdbc.update("insert into dbo.mydo_job_detail values (?,'HPR-1',?,?,?,'','',?,'LOT')",
id, link, document, spare1, product);
}
private void refreshInbound() throws Exception {
String resource = System.getProperty("inbound.migration", "/db/sqlserver/upgrade-shadow-v33-to-v34.sql");
try (var stream = getClass().getResourceAsStream(resource)) {
assertThat(stream).isNotNull();
String sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("''", "'");
int insert = sql.indexOf("INSERT INTO alg.shadow_inbound_request_snapshot (");
int start = sql.lastIndexOf(";WITH", insert);
int end = sql.indexOf("INSERT INTO alg.shadow_outbound_request_snapshot (", insert);
assertThat(start).isGreaterThan(0);
// Only translate SQL Server syntax. Execute the migration's actual projection/predicates.
sql = sql.substring(start, end).replace("WITH (NOLOCK)", "")
.replace("@warehouse_id", "'01'").replace("@snapshot_generation", "42")
.replace("@synchronized_at", "TIMESTAMP '2026-09-07 08:20:17'")
.replace("@numeric_warehouse_id", "1")
.replace("DATALENGTH(", "OCTET_LENGTH(")
.replace("TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockin.warehouse_id)))",
"CAST(LTRIM(RTRIM(stockin.warehouse_id)) AS decimal(38,0))")
.replace("TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(job.warehouse_id)))",
"CAST(LTRIM(RTRIM(job.warehouse_id)) AS decimal(38,0))");
// H2 accepts INSERT ... WITH ... SELECT, SQL Server requires WITH ... INSERT ... SELECT.
int insertStart = sql.indexOf("INSERT INTO");
int selectStart = sql.indexOf("SELECT", insertStart);
jdbc.execute(sql.substring(insertStart, selectStart) + sql.substring(1, insertStart)
+ sql.substring(selectStart));
}
}
}

View File

@@ -0,0 +1,174 @@
package com.ruoyi.algorithm.shadow;
import com.ruoyi.algorithm.inbound.InboundAssignment;
import com.ruoyi.algorithm.inbound.InboundBorrowType;
import com.ruoyi.algorithm.inbound.InboundLadderRung;
import com.ruoyi.algorithm.inbound.InboundPlan;
import com.ruoyi.algorithm.integration.SchedulingFacade;
import com.ruoyi.algorithm.repository.VersionToken;
import com.ruoyi.algorithm.service.InboundPlanningResult;
import com.ruoyi.algorithm.shadow.mapper.ShadowInputMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.SimpleTransactionStatus;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ShadowInboundValidationTest {
private final PublishedShadowSnapshot snapshot = new PublishedShadowSnapshot(
42, "01", Instant.parse("2026-09-07T08:20:17Z"), "ABC123");
private ShadowInputMapper mapper;
private SchedulingFacade facade;
private ShadowPlanningEngine engine;
private ShadowInboundReference reference;
@BeforeEach
void prepare() {
mapper = mock(ShadowInputMapper.class);
facade = mock(SchedulingFacade.class);
var transactions = mock(PlatformTransactionManager.class);
when(transactions.getTransaction(any())).thenReturn(new SimpleTransactionStatus());
var properties = new ShadowObservationProperties();
properties.setWarehouseId("01");
engine = new ShadowPlanningEngine(facade, mapper, properties, transactions);
var descriptor = new ShadowSnapshotDescriptor();
descriptor.setWarehouseId("01");
descriptor.setSnapshotGeneration(42);
descriptor.setSnapshotStatus("PUBLISHED");
descriptor.setSynchronizedAt(snapshot.synchronizedAt());
descriptor.setSourceSignature("ABC123");
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor);
reference = new ShadowInboundReference();
reference.setRequestId("HPR-1");
reference.setTrayNo("TRAY-1");
reference.setWarehouseId("01");
reference.setProjectNo("PROJECT-JOB");
reference.setProductId("PRODUCT-1");
reference.setLotNo("LOT");
reference.setSpare1("201");
reference.setSpare2("");
reference.setSpare3("");
reference.setFaultFlag("0");
reference.setPredicateVersion(1);
reference.setDetailCount(1);
reference.setFieldGroupCount(1);
reference.setProductScopeCount(1);
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,
1, 2, 3, 0, false, false, "fixture proposal");
when(facade.planInboundFromPublishedSnapshot(eq("01"), any()))
.thenReturn(new InboundPlanningResult(InboundPlan.assigned(assignment), VersionToken.numeric(8)));
when(mapper.insertComparison(any(), any(), any(), anyLong(), any(), any(), any(), any())).thenReturn(1);
}
@Test
void oldSnapshotCannotBecomeValidatedEvidence() {
reference.setPredicateVersion(null);
assertSkipped("SKIPPED_MISSING_PREDICATE_INPUT");
}
@Test
void noDetailsAreNotReportedAsReady() {
reference.setDetailCount(0);
assertSkipped("SKIPPED_MISSING_TASK_DETAILS");
}
@Test
void heterogeneousSpareFieldsCannotImpersonateLegacyUnorderedTopOne() {
reference.setDetailCount(45);
reference.setFieldGroupCount(2);
reference.setProductScopeCount(45);
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);
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
void duplicateJobsPreventUnambiguousLegacyComparison() {
reference.setTrayJobCount(2);
assertSkipped("SKIPPED_AMBIGUOUS_TRAY_JOB");
}
@Test
void incompleteGlobalLegacyScopeIsNotClaimedEquivalent() {
reference.setLegacyScopeComplete(0);
assertSkipped("SKIPPED_INCOMPLETE_LEGACY_SCOPE");
}
@Test
void commonProjectNeedsExplicitPolicyMappingNotAnInventedProject() {
for (String project : new String[]{null, "", "999", "COMMON"}) {
reference.setProjectNo(project);
assertSkipped("SKIPPED_UNSUPPORTED_COMMON_PROJECT");
}
}
@Test
void validatedProposalUsesTaskProjectAndPreservesFieldEvidence() {
var result = plan();
assertThat(result.resultCode()).isEqualTo("READY");
assertThat(result.reason()).contains("inboundPredicateVersion=1", "spare1=201", "faultFlag=0",
"detailCount=1", "fieldGroupCount=1", "productScopeCount=1", "legacyEligibility=STRICT_GROUP_OR_EMPTY");
verify(facade).planInboundFromPublishedSnapshot(eq("01"), argThat(request ->
request.requestId().equals("HPR-1") && request.projectNo().equals("PROJECT-JOB")));
verify(facade, never()).planInbound(any(), any());
}
@Test
void ineligibleProposalCannotBecomeMatchEvenWhenSameAsLegacy() {
when(mapper.countInboundEligibleSpace("01", 42, "31331", "TRAY-1", "SPACE-9")).thenReturn(0);
var result = plan();
assertThat(result.resultCode()).isEqualTo("SKIPPED_LEGACY_CONSTRAINT");
assertThat(result.proposedSpaceId()).isNull();
assertThat(result.reason()).contains("unverifiedProposal=SPACE-9");
engine.compareInbound(result, "10", "SPACE-9");
verify(mapper).insertComparison(any(), eq("01"), eq("INBOUND_COMPARE"), eq(42L), any(), any(),
eq("NOT_COMPARABLE"), any());
}
@Test
void failedLegacyAllocationCannotBecomeMatch() {
engine.compareInbound(plan(), "2", "SPACE-9");
verify(mapper).insertComparison(any(), eq("01"), eq("INBOUND_COMPARE"), eq(42L), any(), any(),
eq("NOT_COMPARABLE"), any());
}
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);
assertThat(result.observed()).isTrue(); // The skip itself is recorded, not a usable proposal.
assertThat(result.proposedSpaceId()).isNull();
verify(facade, never()).planInboundFromPublishedSnapshot(any(), any());
}
}

View File

@@ -9,6 +9,7 @@ import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.sql.SQLException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ArrayBlockingQueue;
@@ -392,6 +393,24 @@ class ShadowObservationGatewayTest {
assertThat(recovered.await(1, TimeUnit.SECONDS)).isTrue(); assertThat(recovered.await(1, TimeUnit.SECONDS)).isTrue();
} }
@Test
void sourceChangedRefreshIsClassifiedAsStaleAndDoesNotRetry() throws Exception {
ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class);
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
SQLException sourceChanged = new SQLException("source changed", "S0001", 51025);
when(engine.refreshSnapshot(anyLong(), eq("INSTANCE-1"))).thenThrow(new RuntimeException(sourceChanged));
CountDownLatch staleMetric = metricLatch(mapper, "SKIP_STALE");
ShadowObservationGateway gateway = gateway(engine, mapper, coordinator(),
new ShadowRuntimeMetrics(), Clock.fixed(NOW, ZoneOffset.UTC), delay -> { });
gateway.start();
assertThat(staleMetric.await(1, TimeUnit.SECONDS)).isTrue();
verify(engine, timeout(1000).times(1)).refreshSnapshot(anyLong(), eq("INSTANCE-1"));
verify(mapper, never()).upsertRuntimeMetric(anyString(), eq("ERROR_REFRESH"), anyLong(),
anyLong(), anyLong(), anyLong(), any(Instant.class));
}
private void assertOutboundUsesLease(boolean exactStock) throws Exception { private void assertOutboundUsesLease(boolean exactStock) throws Exception {
ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class); ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class);
ShadowInputMapper mapper = mock(ShadowInputMapper.class); ShadowInputMapper mapper = mock(ShadowInputMapper.class);

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

@@ -49,9 +49,11 @@ class ShadowPlanningEngineTest {
CapturingTransactionManager transactions = new CapturingTransactionManager(); CapturingTransactionManager transactions = new CapturingTransactionManager();
ShadowInputMapper mapper = mock(ShadowInputMapper.class); ShadowInputMapper mapper = mock(ShadowInputMapper.class);
SchedulingFacade facade = mock(SchedulingFacade.class); SchedulingFacade facade = mock(SchedulingFacade.class);
ShadowInboundReference input = inboundReference("W02"); 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.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT)); when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT));
when(facade.planInboundFromPublishedSnapshot(eq("01"), any())) when(facade.planInboundFromPublishedSnapshot(eq("01"), any()))
.thenReturn(new InboundPlanningResult(InboundPlan.assigned(inboundAssignment()), .thenReturn(new InboundPlanningResult(InboundPlan.assigned(inboundAssignment()),
@@ -72,7 +74,7 @@ class ShadowPlanningEngineTest {
assertThat(observation.reason()) assertThat(observation.reason())
.contains("snapshotGeneration=42", "snapshotSync=2026-09-03T00:00:00Z", .contains("snapshotGeneration=42", "snapshotSync=2026-09-03T00:00:00Z",
"sourceSignature=ABC123", "task=TASK-1", "tray=TRAY-1", "sourceSignature=ABC123", "task=TASK-1", "tray=TRAY-1",
"request=JOB-1", "rawWarehouse=W02", "project=PROJECT-1", "request=JOB-1", "rawWarehouse=1", "project=PROJECT-1",
"product=PRODUCT-1", "lot=LOT-1", "shadowCandidates=3"); "product=PRODUCT-1", "lot=LOT-1", "shadowCandidates=3");
verify(mapper).selectInboundInput("01", 42L, "TASK-1", "TRAY-1"); verify(mapper).selectInboundInput("01", 42L, "TASK-1", "TRAY-1");
verify(facade).planInboundFromPublishedSnapshot(eq("01"), any()); verify(facade).planInboundFromPublishedSnapshot(eq("01"), any());
@@ -88,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);
@@ -103,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());
} }
@@ -171,7 +173,7 @@ class ShadowPlanningEngineTest {
true, 42L, "01", SNAPSHOT.synchronizedAt(), SNAPSHOT.sourceSignature(), true, 42L, "01", SNAPSHOT.synchronizedAt(), SNAPSHOT.sourceSignature(),
"JOB-1", "READY", "SPACE-9", "shadow diagnostic"); "JOB-1", "READY", "SPACE-9", "shadow diagnostic");
engine.compareInbound(observation, "READY", "SPACE-9"); engine.compareInbound(observation, "10", "SPACE-9");
verify(mapper).insertComparison(any(), eq("01"), eq("INBOUND_COMPARE"), eq(42L), verify(mapper).insertComparison(any(), eq("01"), eq("INBOUND_COMPARE"), eq(42L),
eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), eq("MATCH"), eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), eq("MATCH"),
@@ -358,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());
} }
@@ -375,6 +379,12 @@ class ShadowPlanningEngineTest {
input.setProjectNo("PROJECT-1"); input.setProjectNo("PROJECT-1");
input.setProductId("PRODUCT-1"); input.setProductId("PRODUCT-1");
input.setLotNo("LOT-1"); input.setLotNo("LOT-1");
input.setPredicateVersion(1);
input.setDetailCount(1);
input.setFieldGroupCount(1);
input.setProductScopeCount(1);
input.setTrayJobCount(1);
input.setLegacyScopeComplete(1);
return input; return input;
} }

View File

@@ -0,0 +1,117 @@
package com.ruoyi.algorithm.shadow;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.jdbc.core.JdbcTemplate;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class ShadowSchemaCheckTest {
private JdbcTemplate jdbc;
private String validation;
@BeforeEach
void createCatalogFixture() throws Exception {
JdbcDataSource source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:" + UUID.randomUUID()
+ ";MODE=MSSQLServer;DB_CLOSE_DELAY=-1;NON_KEYWORDS=PRECISION,SCALE");
jdbc = new JdbcTemplate(source);
jdbc.execute("create schema sys");
jdbc.execute("""
create table sys.columns (object_id varchar(150), name varchar(80), column_id int,
user_type_id varchar(80), max_length int, precision int, scale int, is_nullable int)
""");
jdbc.execute("""
create table expected_columns (table_name varchar(80), column_name varchar(80),
type_name varchar(80), max_length int, precision_value int, scale_value int)
""");
for (String table : new String[]{"inventory_item_snapshot", "shadow_outbound_request_snapshot"}) {
for (String column : new String[]{"spare1", "spare2", "spare3", "voltage", "phase"}) {
column(table, column, "varchar", 255, 0, 0);
}
}
column("inventory_item_snapshot", "tray_job_count", "int", 4, 10, 0);
column("shadow_outbound_request_snapshot", "predicate_version", "int", 4, 10, 0);
column("shadow_outbound_request_snapshot", "allocated_quantity", "decimal", 17, 38, 6);
column("cargo_space_snapshot", "this_cargo_area", "varchar", 255, 0, 0);
for (String name : new String[]{"spare1", "spare2", "spare3", "fault_flag"}) {
column("shadow_inbound_request_snapshot", name, "varchar", 255, 0, 0);
}
for (String name : new String[]{"detail_count", "field_group_count", "product_scope_count",
"tray_job_count", "legacy_scope_complete", "predicate_version"}) {
column("shadow_inbound_request_snapshot", name, "int", 4, 10, 0);
}
Path root = Path.of(System.getProperty("user.dir")).toAbsolutePath();
while (root != null && !Files.isDirectory(root.resolve("scripts"))) root = root.getParent();
assertThat(root).isNotNull();
String sql = Files.readString(root.resolve("scripts/shadow-v34-schema-check.sql")).replace("\r\n", "\n");
int insertStart = sql.indexOf("INSERT INTO @columns VALUES");
jdbc.execute(sql.substring(insertStart, sql.indexOf(';', insertStart) + 1)
.replace("@columns", "expected_columns"));
int selectStart = sql.indexOf("SELECT 1 FROM @columns expected");
validation = sql.substring(selectStart, sql.indexOf(")\n THROW 51074", selectStart))
.replace("@columns", "expected_columns")
// Model catalog functions with fixture identifiers; retain the production predicate.
.replace("OBJECT_ID(N'alg.' + expected.table_name)", "('alg.' + expected.table_name)")
.replace("TYPE_NAME(column_info.user_type_id)", "column_info.user_type_id");
}
@AfterEach
void closeCatalogFixture() {
if (jdbc != null) jdbc.execute("shutdown");
}
@Test
void completeCompatibleCatalogHasNoViolations() {
assertThat(jdbc.queryForList(validation)).isEmpty();
}
@ParameterizedTest
@ValueSource(strings = {"spare1", "spare2", "spare3", "fault_flag", "detail_count", "field_group_count",
"product_scope_count", "tray_job_count", "legacy_scope_complete", "predicate_version"})
void missingInboundColumnIsRejected(String name) {
jdbc.update("delete from sys.columns where object_id='alg.shadow_inbound_request_snapshot' and name=?", name);
assertThat(jdbc.queryForList(validation)).hasSize(1);
}
@ParameterizedTest
@ValueSource(strings = {"user_type_id='nvarchar'", "max_length=80", "precision=1", "scale=1", "is_nullable=0"})
void incompatibleColumnMetadataIsRejected(String change) {
jdbc.execute("update sys.columns set " + change + " where object_id='alg.cargo_space_snapshot'");
assertThat(jdbc.queryForList(validation)).hasSize(1);
}
@Test
void missingV33PrerequisiteIsRejected() {
jdbc.execute("delete from sys.columns where object_id='alg.inventory_item_snapshot' and name='spare2'");
assertThat(jdbc.queryForList(validation)).hasSize(1);
}
@ParameterizedTest
@ValueSource(strings = {"-- INBOUND_JOB_FIELDS_V1", "-- INBOUND_JOB_FIELDS_V1 field_group_count"})
void repeatedUpgradeRejectsProcedureWithIncompleteVersionEvidence(String definition) throws Exception {
try (var stream = getClass().getResourceAsStream("/db/sqlserver/upgrade-shadow-v33-to-v34.sql")) {
assertThat(stream).isNotNull();
String sql = new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
int start = sql.indexOf("IF @installed_definition IS NULL") + 3;
int end = sql.indexOf("THROW 51071", start);
String predicate = sql.substring(start, end)
.replace("@installed_definition", "'" + definition + "'");
assertThat(jdbc.queryForObject("select case when " + predicate + " then 1 else 0 end", Integer.class))
.isEqualTo(1);
}
}
private void column(String table, String name, String type, int length, int precision, int scale) {
jdbc.update("insert into sys.columns values (?,?,1,?,?,?,?,1)",
"alg." + table, name, type, length, precision, scale);
}
}

View File

@@ -125,6 +125,40 @@ class ShadowV3OperationsPackageTest {
assertThat(failure.output()).doesNotContain("INSTALL_OK"); assertThat(failure.output()).doesNotContain("INSTALL_OK");
} }
@Test
void preflightCannotApprovePackageWhenSchemaValidationFails() throws Exception {
Assumptions.assumeTrue(isWindows(), "CMD behavior is verified on Windows");
Path fixture = Files.createDirectories(tempDir.resolve("schema guard fixture"));
Path repo = fixture.resolve("repo with spaces");
cloneRepository(repo);
Path bin = writeSqlcmd13Fixture(fixture);
Path script = root.resolve("scripts/shadow-v3-preflight.cmd");
CmdResult failed = runCmd(script, bin, repo.toString(), gitHead(repo), "SERVER", "FAIL_SCHEMA");
assertThat(failed.exitCode()).as(failed.output()).isEqualTo(1);
assertThat(failed.output()).contains("schema validation failed").doesNotContain("PREFLIGHT_OK");
CmdResult valid = runCmd(script, bin, repo.toString(), gitHead(repo), "SERVER", "FT546-wms");
assertThat(valid.exitCode()).as(valid.output()).isZero();
assertThat(valid.output()).contains("FIXTURE_INPUT=shadow-v34-schema-check.sql", "PREFLIGHT_OK");
}
@Test
void v34InstallerSupportsDirectUpgradeAndPropagatesLastStageFailure() throws Exception {
Assumptions.assumeTrue(isWindows(), "CMD behavior is verified on Windows");
Path bin = writeSqlcmd13Fixture(Files.createDirectories(tempDir.resolve("v34 fixture")));
Path script = root.resolve("scripts/shadow-v3-install.cmd");
CmdResult direct = runCmd(script, bin, "SERVER", "FT546-wms", "v34");
assertThat(direct.exitCode()).as(direct.output()).isZero();
assertThat(direct.output()).contains("FIXTURE_INPUT=upgrade-shadow-v33-to-v34.sql", "INSTALL_OK")
.doesNotContain("FIXTURE_INPUT=upgrade-shadow-v3-to-v33.sql", "FIXTURE_INPUT=install-shadow.sql");
CmdResult clean = runCmd(script, bin, "SERVER", "FT546-wms", "clean");
assertThat(clean.exitCode()).as(clean.output()).isZero();
assertThat(clean.output()).contains("FIXTURE_INPUT=install-shadow.sql",
"FIXTURE_INPUT=upgrade-shadow-v3-to-v33.sql", "FIXTURE_INPUT=upgrade-shadow-v33-to-v34.sql");
CmdResult failed = runCmd(script, bin, "SERVER", "FAIL_V34", "clean");
assertThat(failed.exitCode()).isEqualTo(1);
assertThat(failed.output()).doesNotContain("INSTALL_OK");
}
@Test @Test
void runbookAndServerAiPromptPreserveTheSafeFieldOrder() throws IOException { void runbookAndServerAiPromptPreserveTheSafeFieldOrder() throws IOException {
String runbook = text("doc/algorithm-shadow/README.md"); String runbook = text("doc/algorithm-shadow/README.md");

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

@@ -0,0 +1,112 @@
package com.ruoyi.pda.controller;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.out.outStockout.domain.OutStockout;
import com.ruoyi.out.outStockout.service.impl.OutStockoutServiceImpl;
import com.ruoyi.out.outStockoutDetail.domain.OutStockoutDetail;
import com.ruoyi.out.outStockoutDetail.service.impl.OutStockoutDetailServiceImpl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class PdaControllerTest {
private final OutStockoutServiceImpl orders = mock(OutStockoutServiceImpl.class);
private final OutStockoutDetailServiceImpl details = mock(OutStockoutDetailServiceImpl.class);
private final PdaController controller = new PdaController();
private MockMvc mvc;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(controller, "outStockoutService", orders);
ReflectionTestUtils.setField(controller, "outStockoutDetailService", details);
mvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@Test
void missingOrderReturnsBusinessMessageWithoutThrowingAnException() throws Exception {
when(orders.selectOutStockoutList(any(OutStockout.class))).thenReturn(Collections.emptyList());
mvc.perform(post("/pda/OutFillPkDetail")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"stockoutId\":\"CKD-NOT-FOUND\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(500))
.andExpect(jsonPath("$.msg").value("未查询到匹配的出库单,请核对出库单号及查询条件后重试。"))
.andExpect(jsonPath("$.data").doesNotExist())
.andExpect(result -> assertThat(result.getResolvedException()).isNull());
verifyNoInteractions(details);
}
@Test
void nullOrderListAlsoReturnsBusinessError() {
OutStockout query = new OutStockout();
when(orders.selectOutStockoutList(query)).thenReturn(null);
AjaxResult result = controller.OutFillPkDetail(query);
assertThat(result.isError()).isTrue();
assertThat(result.get("msg")).isEqualTo("未查询到匹配的出库单,请核对出库单号及查询条件后重试。");
verifyNoInteractions(details);
}
@Test
void matchingOrderStillReturnsItsDetails() throws Exception {
OutStockout order = new OutStockout();
order.setStockoutGuid("order-guid");
OutStockoutDetail detail = new OutStockoutDetail();
detail.setStockoutDetailId("CKD-EXISTING-1");
when(orders.selectOutStockoutList(any(OutStockout.class))).thenReturn(List.of(order));
when(details.selectOutStockoutDetailByStockoutGuid("order-guid")).thenReturn(List.of(detail));
mvc.perform(post("/pda/OutFillPkDetail")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"stockoutId\":\"CKD-EXISTING\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data[0].stockoutDetailId").value("CKD-EXISTING-1"));
verify(details).selectOutStockoutDetailByStockoutGuid("order-guid");
}
@Test
void existingOrderWithoutDetailsRetainsSuccessfulEmptyResponse() {
OutStockout order = new OutStockout();
order.setStockoutGuid("order-guid");
when(orders.selectOutStockoutList(order)).thenReturn(List.of(order));
when(details.selectOutStockoutDetailByStockoutGuid("order-guid")).thenReturn(Collections.emptyList());
AjaxResult result = controller.OutFillPkDetail(order);
assertThat(result.isSuccess()).isTrue();
assertThat(result.get("data")).isEqualTo(Collections.emptyList());
}
@Test
void databaseFailureIsNotMisreportedAsMissingOrder() {
OutStockout query = new OutStockout();
DataAccessResourceFailureException failure = new DataAccessResourceFailureException("Database unavailable");
when(orders.selectOutStockoutList(query)).thenThrow(failure);
assertThatThrownBy(() -> controller.OutFillPkDetail(query)).isSameAs(failure);
verifyNoInteractions(details);
}
}

View File

@@ -0,0 +1,136 @@
package com.ruoyi.pda.service;
import com.ruoyi.in.stockindetail.domain.InStockinDetail;
import com.ruoyi.in.stockindetail.service.IInStockinDetailService;
import com.ruoyi.job.mydojob.domain.MydoJob;
import com.ruoyi.job.mydojob.mapper.MydoJobMapper;
import com.ruoyi.stock.stock.domain.BasStock;
import com.ruoyi.stock.stock.mapper.BasStockMapper;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class HalfTrayEligibilityTest {
private JdbcTemplate jdbc;
private SqlSessionFactory sessions;
@BeforeEach
void setUp() throws Exception {
JdbcDataSource dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MSSQLServer;DB_CLOSE_DELAY=-1");
jdbc = new JdbcTemplate(dataSource);
jdbc.execute("create table mydo_job(job_id varchar(80), in_or_out varchar(20))");
jdbc.execute("create table mydo_job_detail(job_id varchar(80), spare4 varchar(80), exit_id varchar(80))");
Configuration configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), dataSource));
configuration.getTypeAliasRegistry().registerAlias("MydoJob", MydoJob.class);
configuration.getTypeAliasRegistry().registerAlias("InStockinDetail", InStockinDetail.class);
try (var stream = getClass().getClassLoader().getResourceAsStream("mapper/job/MydoJobMapper.xml")) {
new XMLMapperBuilder(stream, configuration, "mapper/job/MydoJobMapper.xml", configuration.getSqlFragments()).parse();
}
sessions = new SqlSessionFactoryBuilder().build(configuration);
}
@AfterEach
void close() {
jdbc.execute("shutdown");
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" "})
void blankCodeDoesNotHideCandidatesForAnUnrelatedProject(String cargoNo) {
job("HPC202609070029", "26", "AD.A-00002.01.26", cargoNo);
InStockinDetail detail = incoming("AD.A-00002.01.32", cargoNo);
IInStockinDetailService inbound = mock(IInStockinDetailService.class);
BasStockMapper stocks = mock(BasStockMapper.class);
when(inbound.selectInStockinDetailByStockinDetailId("5673093")).thenReturn(detail);
BasStock tray333 = new BasStock();
tray333.setTrayCode("T0000333");
BasStock tray343 = new BasStock();
tray343.setTrayCode("T0000343");
when(stocks.selectHasHalfTray(detail)).thenReturn(List.of(tray333, tray343));
try (SqlSession session = sessions.openSession()) {
PdaServiceImp service = new PdaServiceImp();
ReflectionTestUtils.setField(service, "inStockinDetailService", inbound);
ReflectionTestUtils.setField(service, "basStockMapper", stocks);
ReflectionTestUtils.setField(service, "mapper", session.getMapper(MydoJobMapper.class));
assertThat(service.selectHasHalfTray("5673093"))
.extracting(BasStock::getTrayCode).containsExactly("T0000333", "T0000343");
}
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" "})
void sameProjectRemainsBlockedEvenWhenCodeIsBlank(String cargoNo) {
job("SAME-PROJECT", "26", "PROJECT", "OTHER-CODE");
assertThat(count(incoming("PROJECT", cargoNo))).isEqualTo(1);
}
@Test
void sameNonemptyCodeStillBlocksAcrossProjectsAndCountsAJobOnlyOnce() {
job("SAME-CODE", "26", "OTHER-PROJECT", "020210100000249");
jdbc.update("insert into mydo_job_detail values(?,?,?)", "SAME-CODE", "OTHER-PROJECT", "020210100000249");
assertThat(count(incoming("PROJECT", "020210100000249"))).isEqualTo(1);
}
@Test
void differentNonemptyCodeDoesNotBlock() {
job("UNRELATED", "26", "OTHER-PROJECT", "OTHER-CODE");
assertThat(count(incoming("PROJECT", "CODE"))).isZero();
}
@Test
void nonHalfTrayJobsAndOrphanDetailsDoNotBlock() {
job("NORMAL-OUT", "21", "PROJECT", "CODE");
jdbc.update("insert into mydo_job_detail values(?,?,?)", "COMPLETED-JOB", "PROJECT", "CODE");
assertThat(count(incoming("PROJECT", "CODE"))).isZero();
}
@Test
void blankProjectDoesNotCreateAnotherEmptyIdentityMatch() {
job("NO-PROJECT", "26", "", "OTHER-CODE");
assertThat(count(incoming("", "CODE"))).isZero();
assertThat(count(incoming("", ""))).isZero();
}
private int count(InStockinDetail detail) {
try (SqlSession session = sessions.openSession()) {
return session.getMapper(MydoJobMapper.class).selectBySpare4OrCargo(detail);
}
}
private void job(String id, String type, String project, String code) {
jdbc.update("insert into mydo_job values(?,?)", id, type);
jdbc.update("insert into mydo_job_detail values(?,?,?)", id, project, code);
}
private InStockinDetail incoming(String project, String code) {
InStockinDetail detail = new InStockinDetail();
detail.setSpare4(project);
detail.setCargoNo(code);
return detail;
}
}

View File

@@ -0,0 +1,113 @@
package com.ruoyi.pda.service;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.in.stockindetail.domain.InStockinDetail;
import com.ruoyi.in.stockindetail.service.IInStockinDetailService;
import com.ruoyi.job.mydojob.mapper.MydoJobMapper;
import com.ruoyi.pda.domain.TrayCodeMsg;
import com.ruoyi.pda.mapper.PdaMapper;
import com.ruoyi.stock.stock.domain.BasStock;
import com.ruoyi.stock.stock.mapper.BasStockMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class PdaHalfTrayOutTest {
private final PdaServiceImp service = new PdaServiceImp();
private final IInStockinDetailService inbound = mock(IInStockinDetailService.class);
private final BasStockMapper stocks = mock(BasStockMapper.class);
private final MydoJobMapper jobs = mock(MydoJobMapper.class);
private final PdaMapper procedures = mock(PdaMapper.class);
private final InStockinDetail detail = new InStockinDetail();
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "inStockinDetailService", inbound);
ReflectionTestUtils.setField(service, "basStockMapper", stocks);
ReflectionTestUtils.setField(service, "mapper", jobs);
ReflectionTestUtils.setField(service, "pdaMapper", procedures);
when(inbound.selectInStockinDetailByStockinDetailId("5673093")).thenReturn(detail);
BasStock tray = new BasStock();
tray.setTrayCode("T0000343");
when(stocks.selectHasHalfTray(detail)).thenReturn(List.of(tray));
}
@Test
void candidatesDisappearingBetweenLookupAndSubmitReturnBusinessError() {
when(stocks.selectHasHalfTray(detail)).thenReturn(Collections.emptyList());
AjaxResult result = service.halfTrayOut(request());
assertThat(result.isError()).isTrue();
assertThat(result.get("msg").toString()).contains("未找到", "刷新");
verifyNoInteractions(procedures);
}
@Test
void newlyCreatedMatchingJobBlocksSubmissionWithAnExplanation() {
when(jobs.selectBySpare4OrCargo(detail)).thenReturn(1);
AjaxResult result = service.halfTrayOut(request());
assertThat(result.isError()).isTrue();
assertThat(result.get("msg").toString()).contains("已有", "半盘出库作业");
verifyNoInteractions(procedures);
}
@Test
void missingInboundDetailReturnsBusinessError() {
when(inbound.selectInStockinDetailByStockinDetailId("5673093")).thenReturn(null);
AjaxResult result = service.halfTrayOut(request());
assertThat(result.isError()).isTrue();
assertThat(result.get("msg").toString()).contains("入库明细", "条码");
verifyNoInteractions(procedures);
}
@Test
void invalidExitIsRejectedBeforeCreatingAJob() {
TrayCodeMsg request = request();
request.setExitId("TD1008");
AjaxResult result = service.halfTrayOut(request);
assertThat(result.isError()).isTrue();
assertThat(result.get("msg").toString()).contains("出口");
verifyNoInteractions(procedures);
}
@Test
void successfulSubmissionKeepsExistingTrayAndExitArguments() {
doAnswer(invocation -> {
HashMap<String, Object> args = invocation.getArgument(0);
assertThat(args).containsEntry("v_tray_code", "T0000343").containsEntry("v_exit_id", "2");
args.put("v_result", 10);
return null;
}).when(procedures).halfTrayOut(any());
assertThat(service.halfTrayOut(request()).isSuccess()).isTrue();
}
@Test
void procedureRejectionKeepsItsBusinessMessage() {
doAnswer(invocation -> {
HashMap<String, Object> args = invocation.getArgument(0);
args.put("v_result", 4);
args.put("v_resultmsg", "该库存已被分配生成作业");
return null;
}).when(procedures).halfTrayOut(any());
AjaxResult result = service.halfTrayOut(request());
assertThat(result.isError()).isTrue();
assertThat(result.get("msg")).isEqualTo("该库存已被分配生成作业");
}
private TrayCodeMsg request() {
TrayCodeMsg request = new TrayCodeMsg();
request.setStockinDetailId("5673093");
request.setExitId("2");
return request;
}
}

View File

@@ -11,12 +11,17 @@ class Sqlcmd13OutputFixture
Console.WriteLine("123 sun.tools.jps.Jps -lv"); Console.WriteLine("123 sun.tools.jps.Jps -lv");
return 0; return 0;
} }
if (Array.IndexOf(args, "-Q") >= 0) return 0;
int input = Array.IndexOf(args, "-i"); int input = Array.IndexOf(args, "-i");
if (input < 0) return 86; if (input < 0) return 86;
string inputName = Path.GetFileName(args[input + 1]); string inputName = Path.GetFileName(args[input + 1]);
Console.WriteLine("FIXTURE_INPUT=" + inputName); Console.WriteLine("FIXTURE_INPUT=" + inputName);
if (inputName == "shadow-v34-schema-check.sql")
return Array.IndexOf(args, "FAIL_SCHEMA") >= 0 ? 67 : 0;
if (inputName == "upgrade-shadow-v3-to-v33.sql" && Array.IndexOf(args, "FAIL_V33") >= 0) if (inputName == "upgrade-shadow-v3-to-v33.sql" && Array.IndexOf(args, "FAIL_V33") >= 0)
return 33; return 33;
if (inputName == "upgrade-shadow-v33-to-v34.sql" && Array.IndexOf(args, "FAIL_V34") >= 0)
return 34;
foreach (string line in File.ReadAllLines(args[input + 1])) foreach (string line in File.ReadAllLines(args[input + 1]))
{ {
if (!line.StartsWith(":out ")) continue; if (!line.StartsWith(":out ")) continue;

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,
"请求参数格式不正确,单号等普通字段请传单个值,不要传对象或数组");
}
/** /**
* 拦截未知的运行时异常 * 拦截未知的运行时异常
*/ */

View File

@@ -10,31 +10,37 @@ set "DATABASE=%~2"
set "MODE=%~3" set "MODE=%~3"
set "SQL_FILE=" set "SQL_FILE="
set "V33_FILE=%~dp0..\fortucky-algorithm\src\main\resources\db\sqlserver\upgrade-shadow-v3-to-v33.sql" set "V33_FILE=%~dp0..\fortucky-algorithm\src\main\resources\db\sqlserver\upgrade-shadow-v3-to-v33.sql"
set "V34_FILE=%~dp0..\fortucky-algorithm\src\main\resources\db\sqlserver\upgrade-shadow-v33-to-v34.sql"
if /I "%~3"=="upgrade" set "SQL_FILE=%~dp0..\fortucky-algorithm\src\main\resources\db\sqlserver\upgrade-shadow-v2-to-v3.sql" if /I "%~3"=="upgrade" set "SQL_FILE=%~dp0..\fortucky-algorithm\src\main\resources\db\sqlserver\upgrade-shadow-v2-to-v3.sql"
if /I "%~3"=="clean" set "SQL_FILE=%~dp0..\fortucky-algorithm\src\main\resources\db\sqlserver\install-shadow.sql" if /I "%~3"=="clean" set "SQL_FILE=%~dp0..\fortucky-algorithm\src\main\resources\db\sqlserver\install-shadow.sql"
if /I "%~3"=="v33" set "SQL_FILE=%V33_FILE%" if /I "%~3"=="v33" set "SQL_FILE=%V33_FILE%"
if not defined SQL_FILE (call :fail "mode must be upgrade, clean or v33" & exit /b 1) if /I "%~3"=="v34" set "SQL_FILE=%V34_FILE%"
if not defined SQL_FILE (call :fail "mode must be upgrade, clean, v33 or v34" & exit /b 1)
if not exist "%SQL_FILE%" (call :fail "SQL package file is missing" & exit /b 1) if not exist "%SQL_FILE%" (call :fail "SQL package file is missing" & exit /b 1)
if not exist "%V33_FILE%" (call :fail "V3.3 SQL package file is missing" & exit /b 1) if not exist "%V33_FILE%" (call :fail "V3.3 SQL package file is missing" & exit /b 1)
if not exist "%V34_FILE%" (call :fail "V3.4 SQL package file is missing" & exit /b 1)
where sqlcmd >nul 2>nul where sqlcmd >nul 2>nul
if errorlevel 1 (call :fail "sqlcmd is not available on PATH" & exit /b 1) if errorlevel 1 (call :fail "sqlcmd is not available on PATH" & exit /b 1)
echo WARNING: stop the WMS shadow-enabled JVM and wait over 120 seconds first. echo WARNING: stop the WMS shadow-enabled JVM and wait over 120 seconds first.
echo Installing FT546WMS shadow V3.3 in %MODE% mode... echo Installing FT546WMS shadow package in %MODE% mode...
sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -i "%SQL_FILE%" sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -i "%SQL_FILE%"
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1
if /I "%MODE%"=="v33" goto :installed if /I "%MODE%"=="v33" goto :installed
if /I "%MODE%"=="v34" goto :installed
sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -i "%V33_FILE%" sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -i "%V33_FILE%"
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1
sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -i "%V34_FILE%"
if errorlevel 1 exit /b 1
:installed :installed
echo INSTALL_OK mode=%MODE% echo INSTALL_OK mode=%MODE%
exit /b 0 exit /b 0
:usage :usage
echo Usage: %~nx0 SQL_SERVER DATABASE upgrade^|clean^|v33 1>&2 echo Usage: %~nx0 SQL_SERVER DATABASE upgrade^|clean^|v33^|v34 1>&2
exit /b 2 exit /b 2
:fail :fail

View File

@@ -52,10 +52,13 @@ if errorlevel 1 (call :fail "jps could not inspect running JVMs" & exit /b 1)
for /f %%I in ('jps -lv 2^>nul ^| findstr /R /I /C:"^[0-9][0-9]* com\.ruoyi\.RuoYiApplication" ^| find /C /V ""') do set "WMS_PROCESS_COUNT=%%I" for /f %%I in ('jps -lv 2^>nul ^| findstr /R /I /C:"^[0-9][0-9]* com\.ruoyi\.RuoYiApplication" ^| find /C /V ""') do set "WMS_PROCESS_COUNT=%%I"
if %WMS_PROCESS_COUNT% GTR 1 (call :fail "more than one FT546WMS JVM is running" & exit /b 1) if %WMS_PROCESS_COUNT% GTR 1 (call :fail "more than one FT546WMS JVM is running" & exit /b 1)
set "PREFLIGHT_SQL=SET NOCOUNT ON; IF SCHEMA_ID(N'alg') IS NULL SELECT N'CLEAN_INSTALL_ELIGIBLE' AS package_state; ELSE BEGIN IF OBJECT_ID(N'alg.shadow_package_manifest', N'U') IS NULL BEGIN; THROW 51050, 'shadow manifest is missing', 1; END; IF (SELECT COUNT(*) FROM alg.shadow_package_manifest) <> 1 OR NOT EXISTS (SELECT 1 FROM alg.shadow_package_manifest WHERE package_id='FT546WMS_SHADOW_ONLY' AND package_version IN ('2026-08-12-1','2026-09-03-3','2026-09-07-33')) BEGIN; THROW 51051, 'shadow manifest is not a supported version', 1; END; IF EXISTS (SELECT 1 FROM alg.shadow_package_manifest WHERE package_id='FT546WMS_SHADOW_ONLY' AND package_version IN ('2026-09-03-3','2026-09-07-33')) BEGIN IF OBJECT_ID(N'alg.shadow_snapshot_state', N'U') IS NULL BEGIN; THROW 51052, 'snapshot state is missing', 1; END; IF OBJECT_ID(N'alg.shadow_instance_heartbeat', N'U') IS NULL BEGIN; THROW 51055, 'shadow instance heartbeat is missing', 1; END; IF NOT EXISTS (SELECT 1 FROM alg.shadow_snapshot_state WHERE warehouse_id='01' AND snapshot_generation>0 AND snapshot_status='PUBLISHED' AND synchronized_at IS NOT NULL AND DATEDIFF(SECOND,synchronized_at,SYSUTCDATETIME()) BETWEEN 0 AND 300 AND source_signature IS NOT NULL AND last_error IS NULL) BEGIN; THROW 51053, 'published warehouse 01 snapshot is unusable', 1; END; IF EXISTS (SELECT 1 FROM alg.shadow_instance_heartbeat WHERE warehouse_id='01' AND ((DATEDIFF(SECOND,last_seen_at,SYSUTCDATETIME()) BETWEEN 0 AND 120) OR (DATEDIFF(SECOND,claimed_at,SYSUTCDATETIME()) BETWEEN 0 AND 120))) BEGIN; THROW 51054, 'a recent shadow instance heartbeat or claim exists', 1; END; END; SELECT package_id, package_version, installed_at FROM alg.shadow_package_manifest; IF OBJECT_ID(N'alg.shadow_snapshot_state', N'U') IS NOT NULL SELECT warehouse_id, snapshot_generation, snapshot_status, synchronized_at, CONVERT(varchar(64), source_signature, 2) AS source_signature, last_error FROM alg.shadow_snapshot_state; END" set "PREFLIGHT_SQL=SET NOCOUNT ON; IF SCHEMA_ID(N'alg') IS NULL SELECT N'CLEAN_INSTALL_ELIGIBLE' AS package_state; ELSE BEGIN IF OBJECT_ID(N'alg.shadow_package_manifest', N'U') IS NULL BEGIN; THROW 51050, 'shadow manifest is missing', 1; END; IF (SELECT COUNT(*) FROM alg.shadow_package_manifest) <> 1 OR NOT EXISTS (SELECT 1 FROM alg.shadow_package_manifest WHERE package_id='FT546WMS_SHADOW_ONLY' AND package_version IN ('2026-08-12-1','2026-09-03-3','2026-09-07-33','2026-09-07-34')) BEGIN; THROW 51051, 'shadow manifest is not a supported version', 1; END; IF EXISTS (SELECT 1 FROM alg.shadow_package_manifest WHERE package_id='FT546WMS_SHADOW_ONLY' AND package_version IN ('2026-09-03-3','2026-09-07-33','2026-09-07-34')) BEGIN IF OBJECT_ID(N'alg.shadow_snapshot_state', N'U') IS NULL BEGIN; THROW 51052, 'snapshot state is missing', 1; END; IF OBJECT_ID(N'alg.shadow_instance_heartbeat', N'U') IS NULL BEGIN; THROW 51055, 'shadow instance heartbeat is missing', 1; END; IF NOT EXISTS (SELECT 1 FROM alg.shadow_snapshot_state WHERE warehouse_id='01' AND snapshot_generation>0 AND snapshot_status='PUBLISHED' AND synchronized_at IS NOT NULL AND DATEDIFF(SECOND,synchronized_at,SYSUTCDATETIME()) BETWEEN 0 AND 300 AND source_signature IS NOT NULL AND last_error IS NULL) BEGIN; THROW 51053, 'published warehouse 01 snapshot is unusable', 1; END; IF EXISTS (SELECT 1 FROM alg.shadow_instance_heartbeat WHERE warehouse_id='01' AND ((DATEDIFF(SECOND,last_seen_at,SYSUTCDATETIME()) BETWEEN 0 AND 120) OR (DATEDIFF(SECOND,claimed_at,SYSUTCDATETIME()) BETWEEN 0 AND 120))) BEGIN; THROW 51054, 'a recent shadow instance heartbeat or claim exists', 1; END; END; SELECT package_id, package_version, installed_at FROM alg.shadow_package_manifest; IF OBJECT_ID(N'alg.shadow_snapshot_state', N'U') IS NOT NULL SELECT warehouse_id, snapshot_generation, snapshot_status, synchronized_at, CONVERT(varchar(64), source_signature, 2) AS source_signature, last_error FROM alg.shadow_snapshot_state; END"
sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -Q "%PREFLIGHT_SQL%" sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -Q "%PREFLIGHT_SQL%"
if errorlevel 1 (call :fail "SQL connectivity or shadow package validation failed" & exit /b 1) if errorlevel 1 (call :fail "SQL connectivity or shadow package validation failed" & exit /b 1)
sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -i "%~dp0shadow-v34-schema-check.sql"
if errorlevel 1 (call :fail "shadow package schema validation failed" & exit /b 1)
echo PREFLIGHT_OK branch=%CURRENT_BRANCH% commit=%CURRENT_COMMIT% wmsProcesses=%WMS_PROCESS_COUNT% echo PREFLIGHT_OK branch=%CURRENT_BRANCH% commit=%CURRENT_COMMIT% wmsProcesses=%WMS_PROCESS_COUNT%
exit /b 0 exit /b 0

View File

@@ -0,0 +1,85 @@
/* Read-only structural gate for installed V3.4 packages. No refresh or business procedure calls. */
SET NOCOUNT ON;
SET LOCK_TIMEOUT 5000;
SET DEADLOCK_PRIORITY LOW;
IF OBJECT_ID(N'alg.shadow_package_manifest', N'U') IS NULL RETURN;
IF NOT EXISTS (SELECT 1 FROM alg.shadow_package_manifest
WHERE package_id = 'FT546WMS_SHADOW_ONLY' AND package_version = '2026-09-07-34') RETURN;
DECLARE @expected TABLE (object_name sysname PRIMARY KEY, object_type char(2) NOT NULL);
INSERT INTO @expected VALUES
(N'shadow_package_manifest','U'),(N'warehouse_policy','U'),(N'project_policy','U'),
(N'space_role','U'),(N'space_topology','U'),(N'inventory_item_snapshot','U'),
(N'space_inventory_summary','U'),(N'lane_state','U'),(N'reservation','U'),
(N'deep_workflow_lock','U'),(N'decision_log','U'),(N'shadow_snapshot_state','U'),
(N'cargo_space_snapshot','U'),(N'active_job_snapshot','U'),
(N'shadow_inbound_request_snapshot','U'),(N'shadow_outbound_request_snapshot','U'),
(N'shadow_runtime_metric','U'),(N'shadow_instance_heartbeat','U'),
(N'v_inventory_item','V'),(N'v_active_job','V'),(N'v_space_state','V'),
(N'usp_rebuild_space_topology','P'),(N'usp_sync_inventory_snapshot','P'),
(N'usp_compute_lane_semantic_fingerprint','P'),(N'usp_rebuild_lane_state','P'),
(N'usp_refresh_dirty_lanes','P'),(N'usp_claim_shadow_instance','P'),
(N'usp_refresh_shadow_snapshot','P');
IF EXISTS (SELECT 1 FROM sys.objects actual LEFT JOIN @expected expected
ON expected.object_name = actual.name AND expected.object_type = actual.type COLLATE DATABASE_DEFAULT
WHERE actual.schema_id = SCHEMA_ID(N'alg') AND actual.is_ms_shipped = 0
AND actual.parent_object_id = 0 AND expected.object_name IS NULL)
THROW 51071, 'V3.4 schema check failed: unknown alg object', 1;
IF EXISTS (SELECT 1 FROM @expected expected LEFT JOIN sys.objects actual
ON actual.schema_id = SCHEMA_ID(N'alg') AND actual.name = expected.object_name
AND actual.type COLLATE DATABASE_DEFAULT = expected.object_type
WHERE actual.object_id IS NULL)
THROW 51072, 'V3.4 schema check failed: expected alg object missing or wrong type', 1;
IF EXISTS (SELECT 1 FROM sys.triggers t JOIN sys.objects parent ON parent.object_id = t.parent_id
WHERE parent.schema_id = SCHEMA_ID(N'alg'))
THROW 51073, 'V3.4 schema check failed: unexpected shadow trigger', 1;
DECLARE @columns TABLE (
table_name sysname, column_name sysname, type_name sysname, max_length smallint,
precision_value tinyint, scale_value tinyint, PRIMARY KEY (table_name, column_name));
INSERT INTO @columns VALUES
(N'inventory_item_snapshot',N'spare1',N'varchar',255,0,0),
(N'inventory_item_snapshot',N'spare2',N'varchar',255,0,0),
(N'inventory_item_snapshot',N'spare3',N'varchar',255,0,0),
(N'inventory_item_snapshot',N'voltage',N'varchar',255,0,0),
(N'inventory_item_snapshot',N'phase',N'varchar',255,0,0),
(N'inventory_item_snapshot',N'tray_job_count',N'int',4,10,0),
(N'shadow_outbound_request_snapshot',N'spare1',N'varchar',255,0,0),
(N'shadow_outbound_request_snapshot',N'spare2',N'varchar',255,0,0),
(N'shadow_outbound_request_snapshot',N'spare3',N'varchar',255,0,0),
(N'shadow_outbound_request_snapshot',N'voltage',N'varchar',255,0,0),
(N'shadow_outbound_request_snapshot',N'phase',N'varchar',255,0,0),
(N'shadow_outbound_request_snapshot',N'allocated_quantity',N'decimal',17,38,6),
(N'shadow_outbound_request_snapshot',N'predicate_version',N'int',4,10,0),
(N'cargo_space_snapshot',N'this_cargo_area',N'varchar',255,0,0),
(N'shadow_inbound_request_snapshot',N'spare1',N'varchar',255,0,0),
(N'shadow_inbound_request_snapshot',N'spare2',N'varchar',255,0,0),
(N'shadow_inbound_request_snapshot',N'spare3',N'varchar',255,0,0),
(N'shadow_inbound_request_snapshot',N'fault_flag',N'varchar',255,0,0),
(N'shadow_inbound_request_snapshot',N'detail_count',N'int',4,10,0),
(N'shadow_inbound_request_snapshot',N'field_group_count',N'int',4,10,0),
(N'shadow_inbound_request_snapshot',N'product_scope_count',N'int',4,10,0),
(N'shadow_inbound_request_snapshot',N'tray_job_count',N'int',4,10,0),
(N'shadow_inbound_request_snapshot',N'legacy_scope_complete',N'int',4,10,0),
(N'shadow_inbound_request_snapshot',N'predicate_version',N'int',4,10,0);
IF EXISTS (
SELECT 1 FROM @columns expected
LEFT JOIN sys.columns column_info
ON column_info.object_id = OBJECT_ID(N'alg.' + expected.table_name)
AND column_info.name = expected.column_name
WHERE column_info.column_id IS NULL
OR TYPE_NAME(column_info.user_type_id) <> expected.type_name
OR column_info.max_length <> expected.max_length
OR column_info.precision <> expected.precision_value
OR column_info.scale <> expected.scale_value
OR column_info.is_nullable <> 1)
THROW 51074, 'V3.4 schema check failed: predicate columns missing or incompatible', 1;
DECLARE @definition nvarchar(max) = OBJECT_DEFINITION(OBJECT_ID(N'alg.usp_refresh_shadow_snapshot', N'P'));
IF @definition IS NULL OR CHARINDEX(N'INBOUND_JOB_FIELDS_V1', @definition) = 0
OR CHARINDEX(N'field_group_count', @definition) = 0 OR CHARINDEX(N'this_cargo_area', @definition) = 0
THROW 51075, 'V3.4 schema check failed: refresh procedure missing, unreadable or obsolete', 1;
PRINT 'V34_SCHEMA_OK';