Compare commits
7 Commits
699310e61d
...
fix/ft546w
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dbdd411bf | |||
| ae7f8c08f4 | |||
| 63ed8f72b1 | |||
| 0bd47e2b2c | |||
| 1a34d72cbb | |||
| 827083e7d6 | |||
| c387ba0b50 |
80
doc/algorithm-shadow/SHADOW_V34_DEPLOYMENT.md
Normal file
80
doc/algorithm-shadow/SHADOW_V34_DEPLOYMENT.md
Normal 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。回到旧代码也保持影子关闭,避免继续采集旧口径样本。
|
||||
|
||||
本次代码修复不代表具备正式切换条件。所有跳过均需单独统计,不能用连续运行天数代替支持范围、样本覆盖和性能验收。
|
||||
31
docs/FT546WMS-V34-REPAIR-0909-deploy.md
Normal file
31
docs/FT546WMS-V34-REPAIR-0909-deploy.md
Normal 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 仍表示源数据在刷新中变化而拒绝发布;不屏蔽该一致性保护,不增加影子刷新超时。
|
||||
|
||||
这次修复不等于批准新算法接管正式业务。两个历史出库代次已被覆盖,尚不能完整回放;请以本包启动后的新样本继续评估。至少按既有观察要求收集同版本有效出入库样本,并单独复核保守比较、跳过、差异和性能。
|
||||
12
docs/superpowers/plans/2026-09-09-shadow-repair-plan.md
Normal file
12
docs/superpowers/plans/2026-09-09-shadow-repair-plan.md
Normal 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 为准,避免预先声明部署完成。
|
||||
25
docs/superpowers/specs/2026-09-09-shadow-repair-design.md
Normal file
25
docs/superpowers/specs/2026-09-09-shadow-repair-design.md
Normal 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;支持的是匹配单个项目/物料的库存行处于多项目/多物料托盘的情况,不声称覆盖任意多项目订单。
|
||||
@@ -472,6 +472,7 @@ public final class InboundPlanner {
|
||||
}
|
||||
|
||||
private boolean usableSlot(Lane lane, SpaceState space, InboundRequest request, InboundContext context) {
|
||||
if (request.eligibleSpaceIds() != null && !request.eligibleSpaceIds().contains(space.spaceId())) return false;
|
||||
if (!lane.usableForInbound() || !space.availableEmpty() || space.role() != SpaceRole.STORAGE) return false;
|
||||
if (request.targetFloor() != null && space.floorNo() != request.targetFloor()) return false;
|
||||
if (request.targetFloor() == null && !request.allowedFloors().isEmpty()
|
||||
|
||||
@@ -12,8 +12,16 @@ public record InboundRequest(
|
||||
Integer entryFloor,
|
||||
Integer targetFloor,
|
||||
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 {
|
||||
if (requestId == null || requestId.isBlank()) throw new IllegalArgumentException("requestId is required");
|
||||
if (trayNo == null || trayNo.isBlank()) throw new IllegalArgumentException("trayNo is required");
|
||||
@@ -22,5 +30,7 @@ public record InboundRequest(
|
||||
}
|
||||
allowedFloors = allowedFloors == null ? Set.of() : Set.copyOf(allowedFloors);
|
||||
batchLaneMode = batchLaneMode == null ? BatchLaneMode.OPEN : batchLaneMode;
|
||||
// null means unrestricted; an empty captured set means no eligible destination.
|
||||
eligibleSpaceIds = eligibleSpaceIds == null ? null : Set.copyOf(eligibleSpaceIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,14 @@ public final class SchedulingFacade {
|
||||
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) {
|
||||
requireAlgorithmMode();
|
||||
return applicationService.planTidy(warehouseId, now);
|
||||
|
||||
@@ -18,7 +18,14 @@ import java.util.Optional;
|
||||
public final class OutboundPlanner {
|
||||
|
||||
public OutboundPlan plan(OutboundRequest request, OutboundContext context) {
|
||||
return plan(request, context, null);
|
||||
}
|
||||
|
||||
public OutboundPlan plan(OutboundRequest request, OutboundContext context, java.util.Set<String> eligibleSpaceIds) {
|
||||
String mode = normalizedMode(request.fifoMode());
|
||||
if (eligibleSpaceIds != null && !"OFF".equals(mode)) {
|
||||
throw new IllegalArgumentException("Captured candidate planning supports FIFO OFF only");
|
||||
}
|
||||
if (!List.of("OFF", "STRICT", "FLEXIBLE").contains(mode)) {
|
||||
return error(OutboundErrorCode.FIFO_INVALID_MODE, "Unsupported FIFO mode",
|
||||
Map.of("fifoMode", String.valueOf(request.fifoMode())));
|
||||
@@ -35,6 +42,7 @@ public final class OutboundPlanner {
|
||||
|
||||
List<SpaceState> physicalMatches = context.spaces().stream()
|
||||
.filter(SpaceState::occupied)
|
||||
.filter(space -> eligibleSpaceIds == null || eligibleSpaceIds.contains(space.spaceId()))
|
||||
.filter(space -> matchesRequest(space, request))
|
||||
.toList();
|
||||
if (physicalMatches.isEmpty()) {
|
||||
|
||||
@@ -359,6 +359,17 @@ public final class JdbcWarehouseRepository implements WarehouseRepository {
|
||||
|
||||
@Override
|
||||
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<>()
|
||||
: laneCodes.stream().filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
if (codes.isEmpty()) return List.of();
|
||||
@@ -393,7 +404,7 @@ public final class JdbcWarehouseRepository implements WarehouseRepository {
|
||||
int capacity = entry.getValue().size();
|
||||
List<SpaceSnapshot> spaces = entry.getValue().stream()
|
||||
.map(raw -> mapSpace(raw, capacity, versions.get(entry.getKey()),
|
||||
inventoryBySpace.getOrDefault(raw.spaceId(), List.of())))
|
||||
inventoryBySpace.getOrDefault(raw.spaceId(), List.of()), mixedProjectSources.contains(raw.spaceId())))
|
||||
.sorted(Comparator.comparingInt((SpaceSnapshot value) -> value.state().depthOrder())
|
||||
.thenComparing(SpaceSnapshot::spaceId)).toList();
|
||||
result.add(new LaneSnapshot(warehouseId, entry.getKey(), versions.get(entry.getKey()), spaces));
|
||||
@@ -720,6 +731,11 @@ public final class JdbcWarehouseRepository implements WarehouseRepository {
|
||||
}
|
||||
|
||||
private SpaceSnapshot mapSpace(RawSpace raw, int capacity, VersionToken version, List<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);
|
||||
if (raw.trayConflict()) conflicts.add(SpaceConflict.TRAY);
|
||||
if (raw.projectConflict()) conflicts.add(SpaceConflict.PROJECT);
|
||||
@@ -734,7 +750,8 @@ public final class JdbcWarehouseRepository implements WarehouseRepository {
|
||||
Instant inboundAt = inventory.stream().map(InventoryItem::inboundAt).filter(Objects::nonNull)
|
||||
.min(Comparator.naturalOrder()).orElse(null);
|
||||
boolean invalid = locked(raw.locked()) || raw.reserved() || raw.hasGap() || raw.depth() == null
|
||||
|| !conflicts.isEmpty() || ("1".equals(raw.spaceStatus()) && (!inventory.isEmpty() || tray != null));
|
||||
|| conflicts.stream().anyMatch(conflict -> conflict != SpaceConflict.PROJECT || !allowMixedProjectSource)
|
||||
|| ("1".equals(raw.spaceStatus()) && (!inventory.isEmpty() || tray != null));
|
||||
SpaceStatus status = invalid ? SpaceStatus.DISABLED : switch (Objects.toString(raw.spaceStatus(), "")) {
|
||||
case "1" -> SpaceStatus.EMPTY;
|
||||
case "2" -> SpaceStatus.OCCUPIED;
|
||||
|
||||
@@ -16,6 +16,10 @@ public interface WarehouseRepository {
|
||||
LanePage pageCandidateLanes(String warehouseId, String afterLaneCode, int pageSize);
|
||||
LanePage pageWarehouseLanes(String warehouseId, String afterLaneCode, int pageSize);
|
||||
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);
|
||||
Set<Integer> loadActiveDeepWorkflowFloors(String warehouseId, String excludingRequestId);
|
||||
boolean acquireDeepWorkflowLock(String requestId, String warehouseId, int floorNo);
|
||||
|
||||
@@ -98,7 +98,8 @@ public final class WarehouseApplicationService {
|
||||
int currentProjectPallets = repository.countProjectPallets(warehouseId, request.projectNo());
|
||||
effectiveRequest = new InboundRequest(request.requestId(), request.trayNo(), request.projectNo(),
|
||||
request.productId(), request.lotNo(), currentProjectPallets <= 1 ? 2 : 4,
|
||||
request.entryFloor(), request.targetFloor(), request.allowedFloors(), request.batchLaneMode());
|
||||
request.entryFloor(), request.targetFloor(), request.allowedFloors(), request.batchLaneMode(),
|
||||
request.eligibleSpaceIds());
|
||||
}
|
||||
List<PendingOutbound> pending = repository.loadPendingOutboundJobs(warehouseId).stream()
|
||||
.map(job -> new PendingOutbound(job.projectNo(), job.productNo(), job.trayNo(),
|
||||
@@ -136,6 +137,18 @@ public final class WarehouseApplicationService {
|
||||
return planOutbound(warehouseId, request, false);
|
||||
}
|
||||
|
||||
/** The caller holds a published snapshot lease and supplies the site's eligible inventory IDs. */
|
||||
public OutboundPlan planShadowOutboundCandidates(String warehouseId, OutboundRequest request,
|
||||
Set<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) {
|
||||
if (refresh) {
|
||||
repository.refreshChangedLanes(warehouseId);
|
||||
@@ -144,6 +157,11 @@ public final class WarehouseApplicationService {
|
||||
}
|
||||
|
||||
private OutboundPlan planOutboundFromCurrentReadModel(String warehouseId, OutboundRequest request) {
|
||||
return planOutboundFromCurrentReadModel(warehouseId, request, null);
|
||||
}
|
||||
|
||||
private OutboundPlan planOutboundFromCurrentReadModel(String warehouseId, OutboundRequest request,
|
||||
Set<String> eligibleStockIds) {
|
||||
RepositoryPolicy policy = repository.loadPolicy(warehouseId);
|
||||
WarehouseSummary summary = repository.loadSummary(warehouseId);
|
||||
OutboundRequest effectiveRequest = request.fifoToleranceMinutes() == null && policy.fifoToleranceMinutes() > 0
|
||||
@@ -187,18 +205,28 @@ public final class WarehouseApplicationService {
|
||||
matches.addAll(repository.searchOutboundStock(warehouseId,
|
||||
OutboundStockSearch.project(request.projectNo(), request.productId())));
|
||||
}
|
||||
if (eligibleStockIds != null) {
|
||||
matches.removeIf(match -> !eligibleStockIds.contains(match.stockId()));
|
||||
Set<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)
|
||||
.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)
|
||||
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
|
||||
List<SpaceSnapshot> buffers = repository.loadRelocationBuffers(warehouseId, floors);
|
||||
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;
|
||||
lanes.stream().flatMap(lane -> lane.spaceStates().stream()).forEach(space -> uniqueSpaces.put(space.spaceId(),
|
||||
productScope != null && matchedSpaceIds.contains(space.spaceId())
|
||||
eligibleStockIds != null && matchedSpaceIds.contains(space.spaceId())
|
||||
? withInventoryScope(space, request.projectNo(), request.productId())
|
||||
: productScope != null && matchedSpaceIds.contains(space.spaceId())
|
||||
? withProductScope(space, productScope) : space));
|
||||
buffers.stream().map(SpaceSnapshot::state).forEach(space -> uniqueSpaces.put(space.spaceId(), space));
|
||||
Map<String, String> rawInboundTimes = new LinkedHashMap<>();
|
||||
@@ -210,7 +238,8 @@ public final class WarehouseApplicationService {
|
||||
OutboundPlan plan = outboundPlanner.plan(effectiveRequest,
|
||||
new OutboundContext(List.copyOf(uniqueSpaces.values()), rawInboundTimes,
|
||||
capacityMode(summary, policy),
|
||||
repository.loadActiveDeepWorkflowFloors(warehouseId, request.requestId())));
|
||||
repository.loadActiveDeepWorkflowFloors(warehouseId, request.requestId())),
|
||||
eligibleStockIds == null ? null : matchedSpaceIds);
|
||||
String resultCode = switch (plan.status()) {
|
||||
case READY -> "READY";
|
||||
case WAITING -> "WAITING_" + plan.waiting().orElseThrow().code().name();
|
||||
@@ -312,10 +341,14 @@ public final class WarehouseApplicationService {
|
||||
}
|
||||
|
||||
private SpaceState withProductScope(SpaceState space, String productId) {
|
||||
return withInventoryScope(space, space.projectNo(), productId);
|
||||
}
|
||||
|
||||
private SpaceState withInventoryScope(SpaceState space, String projectNo, String productId) {
|
||||
return new SpaceState(space.spaceId(), space.zoneCode(), space.laneCode(), space.depthOrder(),
|
||||
space.laneCapacity(), space.floorNo(), space.aisleNo(), space.groupCoding(), space.rowCode(),
|
||||
space.role(), space.status(), space.outboundTaskActive(), space.inventoryProjectConflict(),
|
||||
space.trayNo(), space.projectNo(), productId, space.lotNo(), space.inboundAt(), space.outboundAt(),
|
||||
space.trayNo(), projectNo, productId, space.lotNo(), space.inboundAt(), space.outboundAt(),
|
||||
space.reserved(), space.rowVersion(), space.physicalStockPresent());
|
||||
}
|
||||
|
||||
|
||||
@@ -756,7 +756,7 @@ BEGIN
|
||||
COUNT(*) AS space_count,
|
||||
SUM(CASE WHEN cargo.space_status = '2' OR ISNULL(stock.stock_detail_count, 0) > 0 THEN 1 ELSE 0 END) AS occupied_count,
|
||||
SUM(CASE WHEN cargo.space_status = '1'
|
||||
AND NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL
|
||||
AND (NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL OR LTRIM(RTRIM(cargo.locked)) IN ('0', 'N'))
|
||||
AND NULLIF(LTRIM(RTRIM(cargo.tray_code)), '') IS NULL
|
||||
AND ISNULL(stock.stock_detail_count, 0) = 0
|
||||
AND ISNULL(role.role, 'STORAGE') = 'STORAGE'
|
||||
|
||||
@@ -2,7 +2,7 @@ SET NOCOUNT 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 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);
|
||||
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'),
|
||||
|
||||
@@ -775,7 +775,7 @@ BEGIN
|
||||
SELECT cargo.warehouse_id, cargo.lane_code, COUNT(*) AS space_count,
|
||||
SUM(CASE WHEN cargo.space_status = '2' OR ISNULL(stock.stock_detail_count, 0) > 0 THEN 1 ELSE 0 END) AS occupied_count,
|
||||
SUM(CASE WHEN cargo.space_status = '1'
|
||||
AND NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL
|
||||
AND (NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL OR LTRIM(RTRIM(cargo.locked)) IN ('0', 'N'))
|
||||
AND NULLIF(LTRIM(RTRIM(cargo.tray_code)), '') IS NULL
|
||||
AND ISNULL(stock.stock_detail_count, 0) = 0
|
||||
AND ISNULL(role.role, 'STORAGE') = 'STORAGE'
|
||||
|
||||
@@ -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
|
||||
@@ -104,6 +104,33 @@ class JdbcWarehouseRepositoryTest {
|
||||
assertThat(repository.countProjectPallets("W1", "PROJECT-W2")).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void mixedProjectPalletIsSelectableByItsEligibleInventoryItem() throws Exception {
|
||||
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('MIXED-SOURCE','W1','R','A','Mixed pallet',9,91,1,'2','',"
|
||||
+ "'MIXED-TRAY','MIXED-LANE','1')");
|
||||
execute("INSERT INTO BAS_STOCK VALUES ('MIX-1','MIXED-SOURCE','MIXED-TRAY','P1','SKU1','LOT','2026-01-01 09:00:00',5,0)");
|
||||
execute("INSERT INTO BAS_STOCK VALUES ('MIX-2','MIXED-SOURCE','MIXED-TRAY','P2','SKU2','LOT','2026-01-01 09:00:00',5,0)");
|
||||
rebuildTopology("W1");
|
||||
var service = new WarehouseApplicationService(repository, new InboundPlanner(), new OutboundPlanner(), new TidyPlanner(), 100);
|
||||
var request = new OutboundRequest("MIX-PICK", null, "P1", "SKU1", "OFF", null, 1, false);
|
||||
|
||||
var plan = service.planShadowOutboundCandidates("W1", request, Set.of("MIX-1"));
|
||||
|
||||
assertThat(plan.status()).isEqualTo(OutboundPlanStatus.READY);
|
||||
assertThat(plan.assignment().orElseThrow().sourceSpaceId()).isEqualTo("MIXED-SOURCE");
|
||||
assertThat(plan.assignment().orElseThrow().projectNo()).isEqualTo("P1");
|
||||
assertThat(plan.assignment().orElseThrow().productId()).isEqualTo("SKU1");
|
||||
assertThat(space(repository.loadLaneSnapshots("W1", List.of("MIXED-LANE")), "MIXED-SOURCE")
|
||||
.state().outboundSelectable()).isFalse();
|
||||
for (String columnValue : List.of("locked='Y'", "status='4'", "tray_code='WRONG'")) {
|
||||
execute("UPDATE BAS_CARGO_SPACE SET " + columnValue + " WHERE cargo_space_id='MIXED-SOURCE'");
|
||||
rebuildTopology("W1");
|
||||
assertThat(service.planShadowOutboundCandidates("W1", request, Set.of("MIX-1")).status())
|
||||
.isNotEqualTo(OutboundPlanStatus.READY);
|
||||
execute("UPDATE BAS_CARGO_SPACE SET locked='',status='2',tray_code='MIXED-TRAY' WHERE cargo_space_id='MIXED-SOURCE'");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusFourPhysicalOuterPalletIsPreservedAndMakesDeepOutboundWait() throws Exception {
|
||||
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('ALLOC-TARGET','W1','RA','A','Target',7,21,2,'2','',"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.ruoyi.Dzp;
|
||||
|
||||
|
||||
import com.ruoyi.Dzp.domain.basLed;
|
||||
import com.ruoyi.Dzp.mapper.BasLedMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -9,7 +8,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api2dzp")
|
||||
@@ -17,104 +16,46 @@ public class DzpSend {
|
||||
@Autowired
|
||||
private BasLedMapper mapper;
|
||||
|
||||
// Both scheduled tasks use the device's existing local port. A busy port is retried next tick.
|
||||
private final ReentrantLock portOwner = new ReentrantLock();
|
||||
|
||||
@Scheduled(fixedDelay = 1000)
|
||||
public void InLed() throws IOException {
|
||||
basLed basLed = new basLed();
|
||||
basLed.setSendFlag("0");
|
||||
basLed.setId(1);
|
||||
List<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();
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
public void InLed() throws IOException { sendPending(false); }
|
||||
|
||||
@Scheduled(fixedDelay = 1000)
|
||||
public void ErrorLed() throws IOException {
|
||||
basLed basLed = new basLed();
|
||||
basLed.setSendFlag("0");
|
||||
basLed.setId(1);
|
||||
List<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();
|
||||
public void ErrorLed() throws IOException { sendPending(true); }
|
||||
|
||||
}
|
||||
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("3")) {
|
||||
MyUdpSocket myUdpSocket = new MyUdpSocket(6666);
|
||||
ProtocolDemo.my_udp = myUdpSocket;
|
||||
String ip = basLeds2.get(0).getIpAddress();
|
||||
if (ProtocolDemo.demo_play_chapterschedule2(ip, "报警信息", basLeds2.get(0).getErrorMsg())) {
|
||||
basLeds2.get(0).setSendFlag("1");
|
||||
mapper.updateBasLed(basLeds2.get(0));
|
||||
private void sendPending(boolean alarm) throws IOException {
|
||||
if (!portOwner.tryLock()) return;
|
||||
try {
|
||||
for (int id = 1; id <= 2; id++) {
|
||||
basLed query = new basLed();
|
||||
query.setId(id);
|
||||
query.setSendFlag("0");
|
||||
var records = mapper.selectBasLedList(query);
|
||||
if (records == null || records.isEmpty()) continue;
|
||||
basLed record = records.get(0);
|
||||
boolean matchingModel = alarm ? "3".equals(record.getModel())
|
||||
: "1".equals(record.getModel()) || "2".equals(record.getModel());
|
||||
if (!matchingModel) continue;
|
||||
boolean sent;
|
||||
try (MyUdpSocket socket = openSocket()) {
|
||||
sent = alarm
|
||||
? ProtocolDemo.demo_play_chapterschedule2(socket, record.getIpAddress(), "报警信息", record.getErrorMsg())
|
||||
: ProtocolDemo.demo_play_chapterschedule4(socket, record.getIpAddress(), "物资编码:", "物资规格:",
|
||||
"1".equals(record.getModel()) ? "入库数量:" : "出库数量:",
|
||||
"托盘条码:" + record.getTrayCode(), record.getSpec(), record.getProduct_id(),
|
||||
"物资名称:", record.getProductName(), record.getQuantity());
|
||||
}
|
||||
if (sent) {
|
||||
record.setSendFlag("1");
|
||||
mapper.updateBasLed(record);
|
||||
}
|
||||
}
|
||||
myUdpSocket.close();
|
||||
} finally {
|
||||
portOwner.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
MyUdpSocket openSocket() throws IOException { return new MyUdpSocket(6666); }
|
||||
}
|
||||
|
||||
@@ -6,88 +6,73 @@ import java.nio.ByteBuffer;
|
||||
import java.nio.channels.DatagramChannel;
|
||||
import java.nio.channels.SelectionKey;
|
||||
import java.nio.channels.Selector;
|
||||
import java.util.Set;
|
||||
|
||||
public class MyUdpSocket {
|
||||
private DatagramChannel channel;
|
||||
private Selector selector;
|
||||
private final int MAX_SIZE = 1280;
|
||||
private static int client_port;
|
||||
private static String client_ip;
|
||||
ByteBuffer receive_buffer = ByteBuffer.allocate(MAX_SIZE);
|
||||
/** One exchange owns its channel, selector, receive buffer and peer address. */
|
||||
public class MyUdpSocket implements AutoCloseable {
|
||||
private final DatagramChannel channel;
|
||||
private final Selector selector;
|
||||
private int clientPort;
|
||||
private String clientIp;
|
||||
private final ByteBuffer receiveBuffer = ByteBuffer.allocate(1280);
|
||||
|
||||
public MyUdpSocket(int port) throws IOException {
|
||||
channel = DatagramChannel.open();
|
||||
selector = Selector.open();
|
||||
Selector opened = null;
|
||||
try {
|
||||
//<2F><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ͨ<EFBFBD><CDA8>Ϊ<EFBFBD><CEAA><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ģʽ
|
||||
opened = Selector.open();
|
||||
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.socket().bind(new InetSocketAddress(port));
|
||||
//Ϊͨ<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>
|
||||
channel.register(selector, SelectionKey.OP_READ);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
channel.bind(new InetSocketAddress(port));
|
||||
channel.register(opened, SelectionKey.OP_READ);
|
||||
selector = opened;
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
if (opened != null) {
|
||||
try { opened.close(); } catch (IOException close) { failure.addSuppressed(close); }
|
||||
}
|
||||
try { channel.close(); } catch (IOException close) { failure.addSuppressed(close); }
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
public final void send(String ip, int port, byte[] buffer, int size) throws IOException {
|
||||
InetSocketAddress client = new InetSocketAddress(ip, port);
|
||||
channel.send(ByteBuffer.wrap(buffer, 0, size), client); //<2F><>EchoClient<6E>ظ<EFBFBD>һ<EFBFBD><D2BB><EFBFBD><EFBFBD><EFBFBD>ݱ<EFBFBD>
|
||||
channel.send(ByteBuffer.wrap(buffer, 0, size), new InetSocketAddress(ip, port));
|
||||
}
|
||||
|
||||
@Override
|
||||
public final void close() throws IOException {
|
||||
selector.close();
|
||||
channel.close();
|
||||
try { selector.close(); } finally { channel.close(); }
|
||||
}
|
||||
|
||||
public final int get_client_port() {
|
||||
return client_port;
|
||||
}
|
||||
|
||||
public final String get_client_ip() {
|
||||
return client_ip;
|
||||
}
|
||||
public final int get_client_port() { return clientPort; }
|
||||
public final String get_client_ip() { return clientIp; }
|
||||
|
||||
public final void get_receive_buffer(byte[] buffer) {
|
||||
receive_buffer.get(buffer);
|
||||
get_receive_packet(buffer, buffer.length);
|
||||
}
|
||||
|
||||
public final int receive() {
|
||||
try {
|
||||
receive_buffer.clear();
|
||||
receiveBuffer.clear();
|
||||
if (selector.select(10) != 0) {
|
||||
Set<SelectionKey> readyKeys = selector.selectedKeys();
|
||||
for (SelectionKey key : readyKeys) {
|
||||
readyKeys.remove(key);
|
||||
if (key.isReadable()) {
|
||||
DatagramChannel dc = (DatagramChannel) key.channel();
|
||||
InetSocketAddress client = (InetSocketAddress) dc.receive(receive_buffer); //<2F><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>һ<EFBFBD><D2BB>Client<6E><74><EFBFBD><EFBFBD><EFBFBD>ݱ<EFBFBD>
|
||||
if (client != null) {
|
||||
client_ip = client.getAddress().getHostAddress();
|
||||
client_port = client.getPort();
|
||||
//System.out.println(client_ip+":"+client_port+" size="+receive_buffer.position());
|
||||
key.interestOps(SelectionKey.OP_READ);
|
||||
return receive_buffer.position();
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
} else {
|
||||
return 0;
|
||||
var keys = selector.selectedKeys().iterator();
|
||||
while (keys.hasNext()) {
|
||||
SelectionKey key = keys.next();
|
||||
keys.remove();
|
||||
if (!key.isValid() || !key.isReadable()) continue;
|
||||
InetSocketAddress peer = (InetSocketAddress) channel.receive(receiveBuffer);
|
||||
if (peer != null) {
|
||||
clientIp = peer.getAddress().getHostAddress();
|
||||
clientPort = peer.getPort();
|
||||
return receiveBuffer.position();
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
throw new java.io.UncheckedIOException("LED receive failed", e);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public final void get_receive_packet(byte[] packet, int size) {
|
||||
int i;
|
||||
for (i = 0; i < size; i++) {
|
||||
packet[i] = receive_buffer.array()[i];
|
||||
}
|
||||
System.arraycopy(receiveBuffer.array(), 0, packet, 0, size);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import java.awt.*;
|
||||
//import java.util.Calendar;
|
||||
|
||||
public class ProtocolDemo {
|
||||
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(ProtocolDemo.class);
|
||||
|
||||
// ====<3D><><EFBFBD>붯<EFBFBD><EBB6AF><EFBFBD><EFBFBD>ʽ<EFBFBD>б<EFBFBD>(<28><>ֵ<EFBFBD><D6B5>0<EFBFBD><30>ʼ)====
|
||||
// 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>
|
||||
public static int udp_receive(byte[] buffer) {
|
||||
return udp_receive(my_udp, buffer);
|
||||
}
|
||||
|
||||
private static int udp_receive(MyUdpSocket socket, byte[] buffer) {
|
||||
int size = 0;
|
||||
size = my_udp.receive();
|
||||
try {
|
||||
size = socket.receive();
|
||||
} catch (java.io.UncheckedIOException e) {
|
||||
log.debug("LED receive attempt failed; the bounded exchange will retry", e);
|
||||
return 0;
|
||||
}
|
||||
if (size > 0) {
|
||||
my_udp.get_receive_packet(buffer, size);
|
||||
socket.get_receive_packet(buffer, size);
|
||||
}
|
||||
return size;
|
||||
}
|
||||
@@ -415,6 +425,20 @@ public class ProtocolDemo {
|
||||
}
|
||||
|
||||
public static boolean send_data1(String ip) {
|
||||
return send_data1(my_udp, ledsender, ip);
|
||||
}
|
||||
|
||||
private static boolean udp_send1(MyUdpSocket socket, String ip, byte[] buffer, int size) {
|
||||
try {
|
||||
socket.send(ip, ledport, buffer, size);
|
||||
return true;
|
||||
} catch (java.io.IOException e) {
|
||||
log.debug("LED send attempt failed; the bounded exchange will retry", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static boolean send_data1(MyUdpSocket socket, LEDSender2010 ledsender, String ip) {
|
||||
int size = 0;
|
||||
byte[] packet = new byte[1280];
|
||||
int i, j, k, x, r;
|
||||
@@ -435,9 +459,9 @@ public class ProtocolDemo {
|
||||
ok = false;
|
||||
for (tx = 0; tx < tx_repeat; tx++) {
|
||||
size = ledsender.pkg_begin(packet, addr);
|
||||
udp_send1(ip, packet, size);
|
||||
if (!udp_send1(socket, ip, packet, size)) continue;
|
||||
for (x = 0; x < 50; x++) {
|
||||
size = udp_receive(packet);
|
||||
size = udp_receive(socket, packet);
|
||||
if (size > 0 && ledsender.parse_trans_respond(packet, size, LEDSender2010.PKC_BEGIN, 0) > 0) {
|
||||
ok = true;
|
||||
break;
|
||||
@@ -459,9 +483,9 @@ public class ProtocolDemo {
|
||||
for (tx = 0; tx < tx_repeat; tx++) {
|
||||
j = i;
|
||||
size = ledsender.pkg_data(packet, addr, i, 512);
|
||||
udp_send1(ip, packet, size);
|
||||
if (!udp_send1(socket, ip, packet, size)) continue;
|
||||
for (x = 0; x < 50; x++) {
|
||||
size = udp_receive(packet);
|
||||
size = udp_receive(socket, packet);
|
||||
if (size > 0) {
|
||||
r = ledsender.parse_trans_respond(packet, size,LEDSender2010.PKC_DATA, i);
|
||||
switch (r) {
|
||||
@@ -490,9 +514,9 @@ public class ProtocolDemo {
|
||||
ok = false;
|
||||
for (tx = 0; tx < tx_repeat; tx++) {
|
||||
size = ledsender.pkg_end(packet, addr, i);
|
||||
udp_send1(ip, packet, size);
|
||||
if (!udp_send1(socket, ip, packet, size)) continue;
|
||||
for (x = 0; x < 50; x++) {
|
||||
size = udp_receive(packet);
|
||||
size = udp_receive(socket, packet);
|
||||
if (size > 0 && ledsender.parse_trans_respond(packet, size, LEDSender2010.PKC_END, i) > 0) {
|
||||
ok = true;
|
||||
break;
|
||||
@@ -870,6 +894,11 @@ public class ProtocolDemo {
|
||||
|
||||
//<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) {
|
||||
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>
|
||||
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, 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>
|
||||
@@ -931,6 +960,11 @@ public class ProtocolDemo {
|
||||
|
||||
|
||||
public static boolean demo_play_chapterschedule4(String ip, String msg1, String msg2, String msg3, String msg,String msg5,String msg6,String msg7,String msg8, String msgQuantity) {
|
||||
return demo_play_chapterschedule4(my_udp, ip, msg1, msg2, msg3, msg, msg5, msg6, msg7, msg8, msgQuantity);
|
||||
}
|
||||
|
||||
public static boolean demo_play_chapterschedule4(MyUdpSocket socket, String ip, String msg1, String msg2, String msg3, String msg, String msg5, String msg6, String msg7, String msg8, String msgQuantity) {
|
||||
LEDSender2010 ledsender = new LEDSender2010();
|
||||
|
||||
//<2F><><EFBFBD>ɽ<EFBFBD>Ŀ<EFBFBD><C4BF><EFBFBD><EFBFBD>
|
||||
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);
|
||||
|
||||
|
||||
return send_data1(ip);
|
||||
return send_data1(socket, ledsender, ip);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
@@ -8,6 +8,37 @@ public class ShadowInboundReference {
|
||||
private String productId;
|
||||
private String lotNo;
|
||||
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() {
|
||||
return requestId;
|
||||
|
||||
@@ -283,6 +283,17 @@ public class ShadowObservationGateway {
|
||||
metrics.increment(ShadowSkipReason.CONCURRENT);
|
||||
} catch (Exception 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);
|
||||
warnFailure("CONTROL", generation, failureStage, shadowFailure, startedNanos);
|
||||
}
|
||||
@@ -290,6 +301,21 @@ public class ShadowObservationGateway {
|
||||
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()
|
||||
throws InterruptedException {
|
||||
if (coordinator.activeLegacyCount() != 0) {
|
||||
|
||||
@@ -99,6 +99,13 @@ public class ShadowPlanningEngine {
|
||||
}
|
||||
List<ShadowStockReference> candidates = inputMapper.selectProjectOutboundCandidates(
|
||||
warehouseId, snapshot.generation(), stockoutDetailId);
|
||||
return withCandidateEvidence(planProjectCandidates(snapshot, stockoutDetailId, reference, candidates), candidates);
|
||||
});
|
||||
}
|
||||
|
||||
private OutboundShadowObservation planProjectCandidates(PublishedShadowSnapshot snapshot, String stockoutDetailId,
|
||||
ShadowStockReference reference, List<ShadowStockReference> candidates) {
|
||||
String warehouseId = snapshot.warehouseId();
|
||||
if (candidates.size() > MAX_OUTBOUND_CANDIDATES) {
|
||||
return unavailableProject(snapshot, stockoutDetailId, reference,
|
||||
"SKIPPED_CANDIDATE_LIMIT", "Eligible candidate set exceeds 200 rows");
|
||||
@@ -117,16 +124,33 @@ public class ShadowPlanningEngine {
|
||||
return unavailableProject(snapshot, stockoutDetailId, reference,
|
||||
"SKIPPED_UNSUPPORTED_PRODUCT_SCOPE", "Planner requires one nonblank candidate product");
|
||||
}
|
||||
// The shared planner is single-source and does not apply the site's optional predicates.
|
||||
// Keep it unchanged; validate its proposal against the generation-qualified candidate set.
|
||||
List<ShadowStockReference> idle = candidates.stream()
|
||||
.filter(candidate -> Integer.valueOf(0).equals(candidate.getTrayJobCount())).toList();
|
||||
if (idle.isEmpty()) {
|
||||
return unavailableProject(snapshot, stockoutDetailId, reference,
|
||||
"SKIPPED_EXISTING_TRAY_JOB", "Existing tray jobs require unsupported delivery compatibility checks");
|
||||
}
|
||||
List<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,
|
||||
projects.get(0), products.get(0), null, null, null, false);
|
||||
OutboundPlan plan = schedulingFacade.planOutboundFromPublishedSnapshot(warehouseId, request);
|
||||
OutboundPlan plan = schedulingFacade.planShadowOutboundCandidates(warehouseId, request,
|
||||
sufficient.stream().map(ShadowStockReference::getStockId).collect(java.util.stream.Collectors.toSet()));
|
||||
if (plan.assignment().isEmpty()) {
|
||||
return toOutboundObservation(snapshot, stockoutDetailId, reference, plan);
|
||||
}
|
||||
var assignment = plan.assignment().orElseThrow();
|
||||
var selected = candidates.stream().filter(candidate ->
|
||||
var selected = sufficient.stream().filter(candidate ->
|
||||
Objects.equals(candidate.getSpaceId(), assignment.sourceSpaceId())
|
||||
&& Objects.equals(candidate.getTrayNo(), assignment.trayNo())
|
||||
&& Objects.equals(candidate.getProjectNo(), assignment.projectNo())
|
||||
@@ -140,10 +164,6 @@ public class ShadowPlanningEngine {
|
||||
return unavailableProject(snapshot, stockoutDetailId, reference,
|
||||
"SKIPPED_EXISTING_TRAY_JOB", "Existing tray jobs require unsupported delivery compatibility checks");
|
||||
}
|
||||
if (!Integer.valueOf(1).equals(stock.getTrayItemCount())) {
|
||||
return unavailableProject(snapshot, stockoutDetailId, reference,
|
||||
"SKIPPED_MULTI_STOCK_TRAY", "Selected tray does not contain exactly one inventory row");
|
||||
}
|
||||
if (stock.getAvailableQuantity() == null
|
||||
|| stock.getAvailableQuantity().compareTo(reference.getRequestedQuantity()) < 0) {
|
||||
return unavailableProject(snapshot, stockoutDetailId, reference,
|
||||
@@ -157,7 +177,14 @@ public class ShadowPlanningEngine {
|
||||
return new OutboundShadowObservation(true, snapshot.generation(), warehouseId,
|
||||
snapshot.synchronizedAt(), snapshot.sourceSignature(), stockoutDetailId,
|
||||
"READY", stock.getStockId(), stock.getSpaceId(), reason.toString());
|
||||
});
|
||||
}
|
||||
|
||||
private OutboundShadowObservation withCandidateEvidence(OutboundShadowObservation observation,
|
||||
List<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,
|
||||
@@ -195,7 +222,7 @@ public class ShadowPlanningEngine {
|
||||
UUID.randomUUID().toString(), observation.warehouseId(), "INBOUND_COMPARE",
|
||||
observation.snapshotGeneration(), observation.snapshotSynchronizedAt(),
|
||||
observation.sourceSignature(),
|
||||
comparisonCode(observation.proposedSpaceId(), legacySpaceId),
|
||||
inboundComparisonCode(observation, legacyResultCode, legacySpaceId),
|
||||
inboundComparisonReason(observation, legacyResultCode, legacySpaceId));
|
||||
requireComparisonInserted(inserted);
|
||||
});
|
||||
@@ -226,17 +253,63 @@ public class ShadowPlanningEngine {
|
||||
inboundDiagnostic(snapshot, taskNo, trayNo, reference,
|
||||
"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(),
|
||||
reference.getProjectNo(), reference.getProductId(), reference.getLotNo(),
|
||||
null, null, null, Set.of(), null);
|
||||
reference.getProjectNo(), multipleProducts ? null : reference.getProductId(),
|
||||
multipleProducts ? null : reference.getLotNo(),
|
||||
null, null, null, Set.of(), null, Set.copyOf(eligibleSpaces));
|
||||
InboundPlanningResult result = schedulingFacade.planInboundFromPublishedSnapshot(warehouseId, request);
|
||||
InboundPlan plan = result.plan();
|
||||
if (plan.assignment().isPresent()) {
|
||||
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,
|
||||
snapshot.synchronizedAt(), snapshot.sourceSignature(),
|
||||
reference.getRequestId(), "READY", assignment.spaceId(),
|
||||
inboundDiagnostic(snapshot, taskNo, trayNo, reference, assignment.reason()));
|
||||
reference.getRequestId(), ambiguousFields ? "READY_CONSERVATIVE" : "READY", assignment.spaceId(),
|
||||
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();
|
||||
return InboundShadowObservation.unavailable(snapshot,
|
||||
@@ -244,6 +317,12 @@ public class ShadowPlanningEngine {
|
||||
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,
|
||||
String requestId, ShadowStockReference reference, OutboundPlan plan) {
|
||||
if (isBlank(requestId)) {
|
||||
@@ -409,6 +488,18 @@ public class ShadowPlanningEngine {
|
||||
IDENTIFIER_MAX_LENGTH);
|
||||
appendField(diagnostic, "shadowCandidates", numberOrUnknown(
|
||||
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);
|
||||
return diagnostic.toString();
|
||||
}
|
||||
@@ -499,6 +590,16 @@ public class ShadowPlanningEngine {
|
||||
return shadowSpaceId.equals(legacySpaceId) ? "MATCH" : "DIFFERENT";
|
||||
}
|
||||
|
||||
private String inboundComparisonCode(InboundShadowObservation observation, String legacyCode, String legacySpace) {
|
||||
if (!observation.observed() || !"10".equals(legacyCode)) return "NOT_COMPARABLE";
|
||||
if (!"READY".equals(observation.resultCode()) && !"READY_CONSERVATIVE".equals(observation.resultCode())) {
|
||||
return "NOT_COMPARABLE";
|
||||
}
|
||||
String result = comparisonCode(observation.proposedSpaceId(), legacySpace);
|
||||
return "READY_CONSERVATIVE".equals(observation.resultCode()) && !"NOT_COMPARABLE".equals(result)
|
||||
? result + "_CONSERVATIVE" : result;
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@ public interface ShadowInputMapper {
|
||||
@Param("generation") long generation, @Param("taskNo") String taskNo,
|
||||
@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,
|
||||
@Param("generation") long generation,
|
||||
@Param("stockoutDetailId") String stockoutDetailId);
|
||||
|
||||
@@ -337,6 +337,9 @@ public class PdaController extends BaseController {
|
||||
public AjaxResult OutFillPkDetail(@RequestBody OutStockout outStockout) {
|
||||
|
||||
List<OutStockout> outStockouts = outStockoutService.selectOutStockoutList(outStockout);
|
||||
if (StringUtils.isEmpty(outStockouts)) {
|
||||
return AjaxResult.error("未查询到匹配的出库单,请核对出库单号及查询条件后重试。");
|
||||
}
|
||||
|
||||
return AjaxResult.success(outStockoutDetailService.selectOutStockoutDetailByStockoutGuid(outStockouts.get(0).getStockoutGuid()));
|
||||
}
|
||||
|
||||
@@ -85,10 +85,20 @@ public class PdaServiceImp implements PdaService {
|
||||
|
||||
@Override
|
||||
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());
|
||||
// InStockin inStockin = inStockinService.selectInStockinByStockinId(inStockinDetail.getStockinId());
|
||||
// inStockinDetail.setSpare4(inStockin.getDepartmentNo());
|
||||
if (inStockinDetail == null) {
|
||||
return AjaxResult.error("未查询到入库明细,请核对条码后重试。");
|
||||
}
|
||||
if (mapper.selectBySpare4OrCargo(inStockinDetail) > 0) {
|
||||
return AjaxResult.error("该项目或匹配编码已有半盘出库作业,请完成后再操作。");
|
||||
}
|
||||
List<BasStock> hasHalfTray = basStockMapper.selectHasHalfTray(inStockinDetail);
|
||||
if (CollUtil.isEmpty(hasHalfTray)) {
|
||||
return AjaxResult.error("未找到可出库的未满托盘,请刷新后重试。");
|
||||
}
|
||||
HashMap<String, Object> map = new HashMap<>();
|
||||
map.put("v_tray_code", hasHalfTray.get(0).getTrayCode());
|
||||
map.put("v_exit_id", trayCodeMsg.getExitId());
|
||||
|
||||
@@ -12,6 +12,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
input.project_no as projectNo,
|
||||
input.product_id as productId,
|
||||
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((
|
||||
select sum(convert(bigint, lane.available_count))
|
||||
from alg.lane_state lane
|
||||
@@ -25,6 +29,68 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
and input.tray_no = #{trayNo}
|
||||
</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 > 0
|
||||
and input.field_group_count > 0 and input.product_scope_count > 0
|
||||
and input.tray_job_count = 1 and input.legacy_scope_complete = 1
|
||||
and nullif(ltrim(rtrim(input.project_no)), '') is not null
|
||||
and input.project_no not in ('999', 'COMMON')
|
||||
and cargo.space_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 <> '1'
|
||||
or lane.locked is null or lane.locked <> '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">
|
||||
item.warehouse_id = #{warehouseId}
|
||||
and item.snapshot_generation = #{generation}
|
||||
@@ -75,6 +141,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
select top (201)
|
||||
item.stock_id as stockId, item.tray_code as trayNo, item.space_id as spaceId,
|
||||
item.project_no as projectNo, item.product_no as productId,
|
||||
item.spare1, item.spare2, item.spare3, item.voltage, item.phase,
|
||||
item.tray_job_count as trayJobCount,
|
||||
item.quantity - item.out_quantity as availableQuantity,
|
||||
(select count(*) from alg.inventory_item_snapshot trayItem
|
||||
|
||||
@@ -298,7 +298,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
|
||||
98
ruoyi-admin/src/test/java/com/ruoyi/Dzp/DzpSendTest.java
Normal file
98
ruoyi-admin/src/test/java/com/ruoyi/Dzp/DzpSendTest.java
Normal 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();
|
||||
}
|
||||
}
|
||||
50
ruoyi-admin/src/test/java/com/ruoyi/Dzp/MyUdpSocketTest.java
Normal file
50
ruoyi-admin/src/test/java/com/ruoyi/Dzp/MyUdpSocketTest.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
@@ -392,6 +393,24 @@ class ShadowObservationGatewayTest {
|
||||
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 {
|
||||
ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class);
|
||||
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
|
||||
|
||||
@@ -59,7 +59,7 @@ class ShadowOutboundValidationTest {
|
||||
candidate.setTrayJobCount(0);
|
||||
when(mapper.selectProjectOutboundReference("01", 459L, "DETAIL")).thenReturn(input);
|
||||
when(mapper.selectProjectOutboundCandidates("01", 459L, "DETAIL")).thenReturn(List.of(candidate));
|
||||
when(facade.planOutboundFromPublishedSnapshot(eq("01"), any())).thenReturn(
|
||||
when(facade.planShadowOutboundCandidates(eq("01"), any(), any())).thenReturn(
|
||||
OutboundPlan.ready(new OutboundAssignment("DETAIL", "TRAY", input.getProjectNo(), "PRODUCT",
|
||||
"SPACE", 1, false, List.of(), List.of(), false, 0, 0, 0,
|
||||
FifoDecision.off(), Optional.empty(), "proposal"), TaskCountEstimate.zero()));
|
||||
@@ -71,7 +71,7 @@ class ShadowOutboundValidationTest {
|
||||
assertThat(result.resultCode()).isEqualTo("READY");
|
||||
assertThat(result.proposedStockId()).isEqualTo("STOCK");
|
||||
assertThat(result.reason()).contains("predicateVersion=1", "requestedQuantity=3", "selectedProject=AD.L-00033.01.01");
|
||||
verify(facade).planOutboundFromPublishedSnapshot(eq("01"), argThat(r -> !r.wholeTrayOrDefault()));
|
||||
verify(facade).planShadowOutboundCandidates(eq("01"), argThat(r -> !r.wholeTrayOrDefault()), eq(java.util.Set.of("STOCK")));
|
||||
verify(mapper, never()).refreshSnapshot(anyString(), anyLong(), anyString());
|
||||
verify(facade, never()).planOutbound(anyString(), any());
|
||||
}
|
||||
@@ -80,7 +80,7 @@ class ShadowOutboundValidationTest {
|
||||
void emptyProjectDerivesOnlyProvenCandidateScope() {
|
||||
input.setProjectNo(null);
|
||||
assertThat(plan().resultCode()).isEqualTo("READY");
|
||||
verify(facade).planOutboundFromPublishedSnapshot(eq("01"), argThat(r -> "AD.L-00033.01.01".equals(r.projectNo())));
|
||||
verify(facade).planShadowOutboundCandidates(eq("01"), argThat(r -> "AD.L-00033.01.01".equals(r.projectNo())), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -134,9 +134,12 @@ class ShadowOutboundValidationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipleRowsOnSelectedTrayAreExplicitlyUnsupported() {
|
||||
void otherRowsOnSelectedTrayDoNotEraseTheEligibleItem() {
|
||||
candidate.setTrayItemCount(2);
|
||||
assertThat(plan().resultCode()).isEqualTo("SKIPPED_MULTI_STOCK_TRAY");
|
||||
var result = plan();
|
||||
assertThat(result.resultCode()).isEqualTo("READY");
|
||||
assertThat(result.proposedStockId()).isEqualTo("STOCK");
|
||||
assertThat(result.reason()).contains("candidateEvidenceComplete=true", "\"trayItems\":2", "\"stock\":\"STOCK\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -155,4 +158,13 @@ class ShadowOutboundValidationTest {
|
||||
private OutboundShadowObservation plan() {
|
||||
return engine.planProjectOutbound(snapshot, "DETAIL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsupportedDuplicateEligibleRowsRemainExplicitAndKeepEvidence() {
|
||||
when(mapper.selectProjectOutboundCandidates("01", 459L, "DETAIL")).thenReturn(List.of(candidate, candidate));
|
||||
var result = plan();
|
||||
assertThat(result.resultCode()).isEqualTo("SKIPPED_AMBIGUOUS_STOCK_SCOPE");
|
||||
assertThat(result.reason()).contains("candidateCount=2", "candidateSha256=");
|
||||
verifyNoInteractions(facade);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,9 +49,11 @@ class ShadowPlanningEngineTest {
|
||||
CapturingTransactionManager transactions = new CapturingTransactionManager();
|
||||
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
|
||||
SchedulingFacade facade = mock(SchedulingFacade.class);
|
||||
ShadowInboundReference input = inboundReference("W02");
|
||||
ShadowInboundReference input = inboundReference("1");
|
||||
input.setShadowCandidateCount(3);
|
||||
when(mapper.selectInboundInput("01", 42L, "TASK-1", "TRAY-1")).thenReturn(input);
|
||||
when(mapper.selectInboundEligibleSpaces("01", 42L, "TASK-1", "TRAY-1")).thenReturn(java.util.List.of("SPACE-9"));
|
||||
when(mapper.countInboundEligibleSpace("01", 42L, "TASK-1", "TRAY-1", "SPACE-9")).thenReturn(1);
|
||||
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT));
|
||||
when(facade.planInboundFromPublishedSnapshot(eq("01"), any()))
|
||||
.thenReturn(new InboundPlanningResult(InboundPlan.assigned(inboundAssignment()),
|
||||
@@ -72,7 +74,7 @@ class ShadowPlanningEngineTest {
|
||||
assertThat(observation.reason())
|
||||
.contains("snapshotGeneration=42", "snapshotSync=2026-09-03T00:00:00Z",
|
||||
"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");
|
||||
verify(mapper).selectInboundInput("01", 42L, "TASK-1", "TRAY-1");
|
||||
verify(facade).planInboundFromPublishedSnapshot(eq("01"), any());
|
||||
@@ -88,7 +90,7 @@ class ShadowPlanningEngineTest {
|
||||
.thenReturn(java.util.List.of(stockReference("01")));
|
||||
when(mapper.selectProjectOutboundReference("01", 42L, "DETAIL-1")).thenReturn(reference);
|
||||
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT));
|
||||
when(facade.planOutboundFromPublishedSnapshot(eq("01"), any()))
|
||||
when(facade.planShadowOutboundCandidates(eq("01"), any(), any()))
|
||||
.thenReturn(OutboundPlan.ready(outboundAssignment(), TaskCountEstimate.zero()));
|
||||
ShadowPlanningEngine engine = engine(facade, mapper);
|
||||
|
||||
@@ -103,7 +105,7 @@ class ShadowPlanningEngineTest {
|
||||
"sourceSignature=ABC123", "detail=DETAIL-1", "rawWarehouse=W02",
|
||||
"project=PROJECT-1", "product=PRODUCT-1", "shadowMatches=1");
|
||||
verify(mapper).selectProjectOutboundReference("01", 42L, "DETAIL-1");
|
||||
verify(facade).planOutboundFromPublishedSnapshot(eq("01"), any());
|
||||
verify(facade).planShadowOutboundCandidates(eq("01"), any(), eq(java.util.Set.of("STOCK-1")));
|
||||
verify(facade, never()).planOutbound(any(), any());
|
||||
}
|
||||
|
||||
@@ -171,7 +173,7 @@ class ShadowPlanningEngineTest {
|
||||
true, 42L, "01", SNAPSHOT.synchronizedAt(), SNAPSHOT.sourceSignature(),
|
||||
"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),
|
||||
eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), eq("MATCH"),
|
||||
@@ -358,6 +360,8 @@ class ShadowPlanningEngineTest {
|
||||
}
|
||||
|
||||
private static ShadowPlanningEngine engine(SchedulingFacade facade, ShadowInputMapper mapper) {
|
||||
when(mapper.selectInboundEligibleSpaces(any(), any(Long.class), any(), any()))
|
||||
.thenReturn(java.util.List.of("SPACE-9"));
|
||||
return new ShadowPlanningEngine(facade, mapper, properties(), new CapturingTransactionManager());
|
||||
}
|
||||
|
||||
@@ -375,6 +379,12 @@ class ShadowPlanningEngineTest {
|
||||
input.setProjectNo("PROJECT-1");
|
||||
input.setProductId("PRODUCT-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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,40 @@ class ShadowV3OperationsPackageTest {
|
||||
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
|
||||
void runbookAndServerAiPromptPreserveTheSafeFieldOrder() throws IOException {
|
||||
String runbook = text("doc/algorithm-shadow/README.md");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,17 @@ class Sqlcmd13OutputFixture
|
||||
Console.WriteLine("123 sun.tools.jps.Jps -lv");
|
||||
return 0;
|
||||
}
|
||||
if (Array.IndexOf(args, "-Q") >= 0) return 0;
|
||||
int input = Array.IndexOf(args, "-i");
|
||||
if (input < 0) return 86;
|
||||
string inputName = Path.GetFileName(args[input + 1]);
|
||||
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)
|
||||
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]))
|
||||
{
|
||||
if (!line.StartsWith(":out ")) continue;
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.ruoyi.framework.web.exception;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.InvalidPropertyException;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
@@ -90,6 +91,23 @@ public class GlobalExceptionHandler
|
||||
return AjaxResult.error(String.format("请求参数类型不匹配,参数[%s]要求类型为:'%s',但输入值为:'%s'", e.getName(), e.getRequiredType().getName(), value));
|
||||
}
|
||||
|
||||
@ExceptionHandler(InvalidPropertyException.class)
|
||||
public AjaxResult handleInvalidPropertyException(InvalidPropertyException e, HttpServletRequest request)
|
||||
{
|
||||
String property = e.getPropertyName();
|
||||
boolean bindingFailure = java.util.Arrays.stream(e.getStackTrace()).anyMatch(frame ->
|
||||
frame.getClassName().equals("org.springframework.web.method.annotation.ModelAttributeMethodProcessor")
|
||||
&& frame.getMethodName().equals("resolveArgument"));
|
||||
if (!bindingFailure || property == null || !property.contains("[")
|
||||
|| !request.getParameterMap().containsKey(property))
|
||||
{
|
||||
return handleRuntimeException(e, request);
|
||||
}
|
||||
log.warn("请求地址'{}',请求参数结构不正确", request.getRequestURI());
|
||||
return AjaxResult.error(HttpStatus.BAD_REQUEST,
|
||||
"请求参数格式不正确,单号等普通字段请传单个值,不要传对象或数组");
|
||||
}
|
||||
|
||||
/**
|
||||
* 拦截未知的运行时异常
|
||||
*/
|
||||
|
||||
92
scripts/mixed-pilot/README.md
Normal file
92
scripts/mixed-pilot/README.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# FT546WMS 指定通道混放试点
|
||||
|
||||
这是旧版分配规则的应急补丁,与影子算法是否接管无关。无需更换 WMS/WCS JAR;安装后默认关闭,启用后才允许新增混放。
|
||||
|
||||
## 试点范围
|
||||
|
||||
| 通道 | 内侧原项目位置 | 外侧可混放位置 | 当前新增容量 |
|
||||
| --- | --- | --- | --- |
|
||||
| C1203 | 120803 | 120703 | 1 托盘 |
|
||||
| C0204 | 020804 | 020704 | 1 托盘 |
|
||||
| C0205 | 020805 | 020705 | 1 托盘 |
|
||||
|
||||
白名单按**整条通道**设置,入库和出库保护覆盖两端。三条双深通道共六个位置,内侧已有货,因此本次最多新增三个同时在库的客居托盘。不会把全库零散空位一起开放。
|
||||
|
||||
现场于 2026-09-10 确认所有项目允许分托盘共用通道,并确认这三个外侧位置适配当前托盘。一个托盘仍只能有一个明确项目;999/无项目托盘保持原 B/D 区规则。项目号和库存归属不改写。
|
||||
|
||||
原规则找到货位时优先采用原分配。原规则返回无货位时,才尝试白名单通道的外侧空位。每通道最多两个项目,内侧不能越过外侧托盘补货;有在途任务或锁定时拒绝。新增混放时,同层必须至少有两个可通行的 E 区空位。该检查不是长期预留,后续移库仍会重新检查和占用目标。
|
||||
|
||||
## CMD 操作
|
||||
|
||||
在服务器 CMD 中进入本文件所在目录。命令使用 Windows 集成认证,CMD 会自动调用系统 PowerShell,无需在命令里填写数据库密码。
|
||||
|
||||
1. 安装(开关仍关闭):
|
||||
|
||||
```bat
|
||||
call mixed-pilot.cmd install "lpc:." "FT546-wms"
|
||||
```
|
||||
|
||||
出现 `INSTALL_OK` 才表示成功。安装核对三个原存储过程的 SHA-256,版本不符、锁等待超过两秒或执行失败时,事务回滚,不覆盖现场改动。脚本创建触发器和替换过程时需要短暂架构锁;业务繁忙导致安装失败时稍后重试,不能去杀业务会话。
|
||||
|
||||
2. 查询配置和现场状态:
|
||||
|
||||
```bat
|
||||
call mixed-pilot.cmd status "lpc:." "FT546-wms"
|
||||
```
|
||||
|
||||
3. 已确认所有项目可混放、托盘适配后开启:
|
||||
|
||||
```bat
|
||||
call mixed-pilot.cmd enable "lpc:." "FT546-wms" CONFIRM-FIT-ALL-PROJECTS
|
||||
```
|
||||
|
||||
出现 `ENABLED` 才表示三条通道均已启用。启用会重新核对内侧库存、外侧空位和在途任务,条件变化则整次拒绝。
|
||||
|
||||
4. 像原来一样创建入库任务。出库优先使用**自动分配**,它会按旧程序先为外侧托盘建立移库,再建立内侧出库。手工选择被挡住的内侧库存、却没有外侧腾挪任务时,会明确拒绝,不能手动把等待任务改为已下发或已完成。
|
||||
|
||||
## 观察和退出
|
||||
|
||||
内侧任务等待期间状态为 `8`,含义是“等待前位清空”;现有界面字典未配置该标签时可能直接显示数字 8。`status` 命令的 `waiting` 给出任务清单。只有外侧货位、库存和在途任务均已清空,内侧任务才恢复状态 `1`,由现有 WMS 定时推送交给 WCS。创建移库任务本身不会释放等待。
|
||||
|
||||
先验证一组完整业务:新项目入库 → 前位正常出库 → 再混放一托 → 后位自动出库并完成前位移库。测试通过后再增加通道数量;本包不开放 A 区深通道,也不切换整套新算法。
|
||||
|
||||
出现异常时先关闭**新增混放**,无需重启:
|
||||
|
||||
```bat
|
||||
call mixed-pilot.cmd disable "lpc:." "FT546-wms"
|
||||
```
|
||||
|
||||
关闭会保留已混放库存的出库保护,不会自动撤销库存位置或移库任务。不要直接删除触发器或覆盖存储过程。
|
||||
|
||||
完整卸载必须先关闭开关,三条试点通道清空、相关移库任务和移出到 E 区的试点托盘也全部处理完:
|
||||
|
||||
```bat
|
||||
call mixed-pilot.cmd uninstall "lpc:." "FT546-wms"
|
||||
```
|
||||
|
||||
卸载恢复三个原过程的完整逻辑、移除试点触发器和辅助过程,保留 `mixed_pilot` 中的原定义与审计表。未清空时拒绝卸载。卸载后的再次安装需要重新准备版本,避免覆盖已有审计资料。
|
||||
|
||||
导出观察记录发回分析:
|
||||
|
||||
```bat
|
||||
call mixed-pilot.cmd status "lpc:." "FT546-wms" > mixed-pilot-status.json
|
||||
```
|
||||
|
||||
同时提供对应时间段 WMS/WCS 日志,以及实际入库、前位出库、后位移库出库的任务号和现场结果。仅有安装成功或一段无报错日志不能证明设备完整流程通过。
|
||||
|
||||
## 已完成的程序验证
|
||||
|
||||
在 SQL Server 2016 上创建独立小型测试数据库,只使用合成数据和读取到的现场原过程,不连接设备或调用业务 HTTP。已验证默认关闭、普通分配优先、白名单与项目一致性、拒绝第三项目、重复分配、前后位派发顺序、移库目标校验、重复完成回调、开关关闭仍保护、两个连接争抢同一空位、安装漂移拒绝及卸载恢复。
|
||||
|
||||
还直接执行了现场 `proc_out_auto_allocation`:确认实际生成的移库任务搬的是外侧客居托盘,内侧出库进入等待,移库完成后才放行。这些是数据库行为验证,设备现场往返仍需通过上述小范围业务试点确认。
|
||||
|
||||
本补丁替换 `proc_allot_space`、`proc_yk_job_add`、`proc_yk_complete` 三个入口并保留原定义,不修改两个出库分配过程。增加三处数据库触发器,涉及 `mydo_job`、`bas_cargo_space`、`bas_stock`。未登记通道仍使用原分配与派发规则;试点启用期间,入库分配增加短时间串行校验,移库增加目标锁检查,争用时可能需要重试。
|
||||
|
||||
开发人员复测(应在安装前采集原过程;测试只创建独立、随机命名的测试数据库):
|
||||
|
||||
```bat
|
||||
powershell.exe -NoProfile -NonInteractive -File capture-test-fixture.ps1 -Server "lpc:." -Database "FT546-wms" > fixture.json
|
||||
powershell.exe -NoProfile -NonInteractive -File test-mixed-pilot.ps1 -Server "lpc:." -FixtureJson fixture.json
|
||||
```
|
||||
|
||||
测试库初始数据文件与日志各 8 MB,上限各 64 MB,正常结束或失败后都会尝试清理。测试不恢复生产库,不调用 WMS/WCS 的业务接口;原序列号过程用测试序列替代,合成表保留字段类型和顺序、放宽非空约束,因此不覆盖全部生产约束及设备流程。
|
||||
20
scripts/mixed-pilot/capture-test-fixture.ps1
Normal file
20
scripts/mixed-pilot/capture-test-fixture.ps1
Normal file
@@ -0,0 +1,20 @@
|
||||
param([Parameter(Mandatory=$true)][string]$Server,[Parameter(Mandatory=$true)][string]$Database)
|
||||
$ErrorActionPreference='Stop'
|
||||
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
|
||||
$builder=[Data.SqlClient.SqlConnectionStringBuilder]::new()
|
||||
$builder['Data Source']=$Server;$builder['Initial Catalog']=$Database;$builder['Integrated Security']=$true
|
||||
$builder['Connect Timeout']=8;$builder['Application Name']='MixedPilotFixtureReadOnly'
|
||||
$cn=[Data.SqlClient.SqlConnection]::new($builder.ConnectionString);$cn.Open()
|
||||
function Query([string]$sql){
|
||||
$c=$cn.CreateCommand();$c.CommandTimeout=10;$c.CommandText='SET NOCOUNT ON;SET LOCK_TIMEOUT 2000;'+$sql
|
||||
$t=[Data.DataTable]::new();try{$r=$c.ExecuteReader();try{$t.Load($r)}finally{$r.Dispose()}}finally{$c.Dispose()}
|
||||
foreach($row in $t.Rows){$o=[ordered]@{};foreach($col in $t.Columns){$v=$row[$col.ColumnName];$o[$col.ColumnName]=if($v -is [DBNull]){$null}else{$v}};[pscustomobject]$o}
|
||||
}
|
||||
try {
|
||||
# Definitions and column metadata only: no production inventory/order/task rows are exported.
|
||||
$fixture=[ordered]@{
|
||||
columns=@(Query "SELECT o.name table_name,c.name column_name,TYPE_NAME(c.user_type_id) type_name,c.max_length,c.precision,c.scale,c.is_nullable,c.is_identity FROM sys.objects o JOIN sys.columns c ON c.object_id=o.object_id WHERE o.schema_id=SCHEMA_ID('dbo') AND o.name IN('bas_cargo_space','bas_stock','mydo_job','mydo_job_detail','mydo_job_finish','bas_led','move_msg','out_stockout','out_stockout_detail') ORDER BY o.name,c.column_id")
|
||||
wms_objects=@(Query "SELECT o.name,m.definition FROM sys.objects o JOIN sys.sql_modules m ON m.object_id=o.object_id WHERE o.schema_id=SCHEMA_ID('dbo') AND o.name IN('cargo_space_group_count','proc_allot_space','proc_yk_job_add','proc_yk_complete','proc_out_auto_allocation','proc_out_hand_allocation')")
|
||||
}
|
||||
$fixture|ConvertTo-Json -Depth 6 -Compress
|
||||
}finally{$cn.Dispose()}
|
||||
101
scripts/mixed-pilot/control-mixed-pilot.ps1
Normal file
101
scripts/mixed-pilot/control-mixed-pilot.ps1
Normal file
@@ -0,0 +1,101 @@
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$Server,
|
||||
[Parameter(Mandatory=$true)][string]$Database,
|
||||
[ValidateSet('status','enable','disable','uninstall')][string]$Mode='status',
|
||||
[switch]$ConfirmPhysicalFit,
|
||||
[switch]$AllProjectsMayMix,
|
||||
[switch]$TestDatabase
|
||||
)
|
||||
$ErrorActionPreference='Stop'
|
||||
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
|
||||
if($TestDatabase -and $Database -notmatch '^FT546WMS_MixedPilot_Test_[a-f0-9]{32}$'){throw 'Invalid test database name'}
|
||||
if(-not $TestDatabase -and $Database -ine 'FT546-wms'){throw 'Only FT546-wms is supported'}
|
||||
$builder=[Data.SqlClient.SqlConnectionStringBuilder]::new()
|
||||
$builder['Data Source']=$Server;$builder['Initial Catalog']=$Database;$builder['Integrated Security']=$true
|
||||
$builder['Connect Timeout']=8;$builder['Application Name']='FT546MixedPilotControl'
|
||||
$cn=[Data.SqlClient.SqlConnection]::new($builder.ConnectionString);$cn.Open();$tx=$null
|
||||
function Cmd([string]$sql){$c=$cn.CreateCommand();$c.CommandText=$sql;$c.CommandTimeout=15;if($tx){$c.Transaction=$tx};$c}
|
||||
function Exec([string]$sql){$c=Cmd $sql;try{[void]$c.ExecuteNonQuery()}finally{$c.Dispose()}}
|
||||
function Scalar([string]$sql){$c=Cmd $sql;try{$c.ExecuteScalar()}finally{$c.Dispose()}}
|
||||
function Query([string]$sql){
|
||||
$c=Cmd $sql;$t=[Data.DataTable]::new()
|
||||
try{$r=$c.ExecuteReader();try{$t.Load($r)}finally{$r.Dispose()}}finally{$c.Dispose()}
|
||||
foreach($row in $t.Rows){$o=[ordered]@{};foreach($col in $t.Columns){$v=$row[$col.ColumnName];$o[$col.ColumnName]=if($v -is [DBNull]){$null}else{$v}};[pscustomobject]$o}
|
||||
}
|
||||
try {
|
||||
Exec 'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET DEADLOCK_PRIORITY LOW;'
|
||||
if((Scalar "SELECT OBJECT_ID('mixed_pilot.control','U')") -is [DBNull]){
|
||||
if($Mode -eq 'status'){Write-Output '{"installed":false}';return};throw 'Pilot is not installed'
|
||||
}
|
||||
if($Mode -eq 'status'){
|
||||
$report=[ordered]@{
|
||||
utc=[DateTime]::UtcNow.ToString('o')
|
||||
control=@(Query 'SELECT * FROM mixed_pilot.control')
|
||||
slots=@(Query 'SELECT l.*,s.cargo_space_id,s.group_coding,s.the_floor,s.status,s.locked,s.tray_code FROM mixed_pilot.lane l LEFT JOIN dbo.bas_cargo_space s ON s.warehouse_id=l.warehouse_id AND s.group_coding_no=l.lane_code ORDER BY l.lane_code,s.group_coding')
|
||||
admissions=@(Query 'SELECT TOP(100) * FROM mixed_pilot.admission ORDER BY created_at DESC')
|
||||
waiting=@(Query "SELECT j.job_id,j.tray_code,j.cargo_space_id,j.status,h.created_at FROM mixed_pilot.dispatch_hold h JOIN dbo.mydo_job j ON j.job_id=h.job_id WHERE j.status='8'")
|
||||
moves=@(Query 'SELECT * FROM mixed_pilot.move_audit WHERE completed_at IS NULL')
|
||||
}
|
||||
$report|ConvertTo-Json -Depth 6;return
|
||||
}
|
||||
$tx=$cn.BeginTransaction()
|
||||
Exec "DECLARE @r int; EXEC @r=sys.sp_getapplock @Resource='FT546.mixed_pilot.inbound',@LockMode='Exclusive',@LockOwner='Transaction',@LockTimeout=1000; IF @r<0 THROW 51142,'An inbound allocation is active; retry later',1;"
|
||||
if($Mode -eq 'disable'){
|
||||
Exec 'UPDATE mixed_pilot.control SET enabled=0 WHERE id=1; UPDATE mixed_pilot.lane SET enabled=0;'
|
||||
$tx.Commit();$tx.Dispose();$tx=$null;Write-Output 'DISABLED: no new mixed admissions; existing mixed-stock dispatch protection retained';return
|
||||
}
|
||||
if((Scalar 'SELECT installed FROM mixed_pilot.control WHERE id=1') -ne 1){throw 'Pilot hooks are already uninstalled'}
|
||||
Exec "IF EXISTS(SELECT 1 FROM mixed_pilot.module_backup WHERE installed_hash<>HASHBYTES('SHA2_256',OBJECT_DEFINITION(OBJECT_ID(object_name))) OR OBJECT_DEFINITION(OBJECT_ID(object_name)) IS NULL) OR EXISTS(SELECT 1 FROM mixed_pilot.installed_module WHERE definition_hash<>HASHBYTES('SHA2_256',OBJECT_DEFINITION(OBJECT_ID(object_name))) OR OBJECT_DEFINITION(OBJECT_ID(object_name)) IS NULL) THROW 51143,'Module drift detected; no changes made',1;"
|
||||
if($Mode -eq 'enable'){
|
||||
if(-not $ConfirmPhysicalFit -or -not $AllProjectsMayMix){throw 'Enable requires -ConfirmPhysicalFit and -AllProjectsMayMix; do not infer these from empty SQL height fields'}
|
||||
Exec @'
|
||||
IF (SELECT COUNT(*) FROM mixed_pilot.lane)<>3 OR EXISTS(SELECT 1 FROM mixed_pilot.lane WHERE warehouse_id<>'01' OR lane_code NOT IN('C1203','C0204','C0205'))
|
||||
THROW 51144,'Pilot whitelist differs from the reviewed three lanes',1;
|
||||
DECLARE @locked int;
|
||||
SELECT @locked=COUNT(*) FROM dbo.bas_cargo_space s WITH(UPDLOCK,HOLDLOCK)
|
||||
JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no;
|
||||
IF @locked<>6 OR EXISTS(SELECT 1 FROM mixed_pilot.lane l WHERE
|
||||
(SELECT COUNT(*) FROM dbo.bas_cargo_space s WHERE s.warehouse_id=l.warehouse_id AND s.group_coding_no=l.lane_code AND TRY_CONVERT(int,s.group_coding)=1 AND s.this_cargo_area='C' AND s.status='2' AND s.locked='N' AND NULLIF(s.tray_code,'') IS NOT NULL)<>1
|
||||
OR (SELECT COUNT(*) FROM dbo.bas_cargo_space s WHERE s.warehouse_id=l.warehouse_id AND s.group_coding_no=l.lane_code AND TRY_CONVERT(int,s.group_coding)=2 AND s.this_cargo_area='C' AND s.status='1' AND s.locked='N' AND NULLIF(s.tray_code,'') IS NULL)<>1)
|
||||
THROW 51145,'A pilot lane no longer has an occupied rear and empty unlocked front; refresh candidate review',1;
|
||||
IF EXISTS(SELECT 1 FROM dbo.mydo_job j WITH(UPDLOCK,HOLDLOCK) JOIN dbo.bas_cargo_space s ON s.cargo_space_id=j.cargo_space_id OR s.cargo_space_id=j.target_cargo_space_id OR s.tray_code=j.tray_code
|
||||
JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no)
|
||||
THROW 51146,'Pilot lane has an active task; no switches changed',1;
|
||||
IF EXISTS(SELECT 1 FROM dbo.bas_cargo_space s JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no
|
||||
WHERE (TRY_CONVERT(int,s.group_coding)=2 AND EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=s.cargo_space_id))
|
||||
OR (TRY_CONVERT(int,s.group_coding)=1 AND (
|
||||
(SELECT COUNT(DISTINCT spare4) FROM dbo.bas_stock x WHERE x.cargo_space_id=s.cargo_space_id)<>1
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=s.cargo_space_id AND (NULLIF(x.spare4,'') IS NULL OR x.spare4='999' OR x.tray_code<>s.tray_code OR x.tray_code IS NULL OR ISNULL(x.out_quantity,0)<>0)))))
|
||||
THROW 51147,'Pilot inventory/project consistency check failed',1;
|
||||
UPDATE mixed_pilot.control SET enabled=1,all_projects_may_mix=1 WHERE id=1;
|
||||
UPDATE mixed_pilot.lane SET enabled=1,physical_fit_confirmed=1;
|
||||
'@
|
||||
$tx.Commit();$tx.Dispose();$tx=$null;Write-Output 'ENABLED: C1203/120703, C0204/020704, C0205/020705; at most three simultaneous guest pallets';return
|
||||
}
|
||||
# Full uninstall requires all trial stock/tasks to be drained, including relocated trial pallets.
|
||||
Exec @'
|
||||
IF EXISTS(SELECT 1 FROM mixed_pilot.control WHERE enabled=1)
|
||||
THROW 51148,'Disable admission before uninstalling',1;
|
||||
IF EXISTS(SELECT 1 FROM dbo.bas_cargo_space s JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no
|
||||
WHERE s.status<>'1' OR NULLIF(s.tray_code,'') IS NOT NULL
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=s.cargo_space_id)
|
||||
OR EXISTS(SELECT 1 FROM dbo.mydo_job j WHERE j.cargo_space_id=s.cargo_space_id OR j.target_cargo_space_id=s.cargo_space_id))
|
||||
OR EXISTS(SELECT 1 FROM mixed_pilot.move_audit a JOIN dbo.bas_stock s ON s.tray_code=a.tray_code)
|
||||
OR EXISTS(SELECT 1 FROM mixed_pilot.move_audit a JOIN dbo.mydo_job j ON j.job_id=a.job_id)
|
||||
OR EXISTS(SELECT 1 FROM mixed_pilot.dispatch_hold h JOIN dbo.mydo_job j ON j.job_id=h.job_id AND j.status='8')
|
||||
THROW 51149,'Trial lanes or relocated trial pallets are not drained; keep protection installed',1;
|
||||
'@
|
||||
$backups=@(Query 'SELECT * FROM mixed_pilot.module_backup')
|
||||
foreach($b in $backups){
|
||||
Exec (('SET ANSI_NULLS '+$(if($b.ansi_nulls){'ON'}else{'OFF'}))+'; SET QUOTED_IDENTIFIER '+$(if($b.quoted_identifier){'ON'}else{'OFF'})+';')
|
||||
$original=[regex]::Replace($b.original_definition,'(?i)\bCREATE\s+PROCEDURE','ALTER PROCEDURE')
|
||||
Exec $original
|
||||
}
|
||||
Exec 'DROP TRIGGER dbo.tr_mixed_pilot_job_guard; DROP TRIGGER dbo.tr_mixed_pilot_slot_release; DROP TRIGGER dbo.tr_mixed_pilot_stock_release;'
|
||||
foreach($name in @('allot_space_with_fallback','original_allot_space','original_yk_job_add','original_yk_complete','check_inbound','try_fallback','check_move','release_ready_jobs')){Exec "DROP PROCEDURE mixed_pilot.[$name]"}
|
||||
Exec 'DROP VIEW mixed_pilot.blocked_jobs; UPDATE mixed_pilot.control SET installed=0,enabled=0 WHERE id=1;'
|
||||
$tx.Commit();$tx.Dispose();$tx=$null
|
||||
Write-Output 'UNINSTALLED: original procedure behavior restored; audit and original definitions retained in mixed_pilot tables'
|
||||
} catch {
|
||||
if($tx){try{$tx.Rollback()}catch{};$tx.Dispose();$tx=$null};throw
|
||||
} finally {$cn.Dispose()}
|
||||
92
scripts/mixed-pilot/install-mixed-pilot.ps1
Normal file
92
scripts/mixed-pilot/install-mixed-pilot.ps1
Normal file
@@ -0,0 +1,92 @@
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$Server,
|
||||
[Parameter(Mandatory=$true)][string]$Database,
|
||||
[switch]$TestDatabase
|
||||
)
|
||||
$ErrorActionPreference='Stop'
|
||||
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
|
||||
if($TestDatabase -and $Database -notmatch '^FT546WMS_MixedPilot_Test_[a-f0-9]{32}$'){throw 'Invalid isolated test database name'}
|
||||
if(-not $TestDatabase -and $Database -ine 'FT546-wms'){throw 'Only FT546-wms is supported'}
|
||||
$builder=[Data.SqlClient.SqlConnectionStringBuilder]::new()
|
||||
$builder['Data Source']=$Server; $builder['Initial Catalog']=$Database; $builder['Integrated Security']=$true
|
||||
$builder['Connect Timeout']=8; $builder['Application Name']='FT546MixedPilotInstaller'
|
||||
$cn=[Data.SqlClient.SqlConnection]::new($builder.ConnectionString);$cn.Open();$tx=$null
|
||||
function Cmd([string]$sql) {
|
||||
$c=$cn.CreateCommand();$c.CommandText=$sql;$c.CommandTimeout=15
|
||||
if($tx){$c.Transaction=$tx};return $c
|
||||
}
|
||||
function Exec([string]$sql) {$c=Cmd $sql;try{[void]$c.ExecuteNonQuery()}finally{$c.Dispose()}}
|
||||
function Scalar([string]$sql) {$c=Cmd $sql;try{$c.ExecuteScalar()}finally{$c.Dispose()}}
|
||||
function Hash([string]$s) {
|
||||
$sha=[Security.Cryptography.SHA256]::Create()
|
||||
try{([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::Unicode.GetBytes($s)))).Replace('-','')}finally{$sha.Dispose()}
|
||||
}
|
||||
function RenameProc([string]$sql,[string]$name) {
|
||||
$regex=[regex]::new('(?i)\bCREATE\s+PROCEDURE\s+(?:\[?dbo\]?\.)?\[?proc_[a-z_]+\]?')
|
||||
if($regex.Matches($sql).Count -ne 1){throw 'Unexpected procedure header'}
|
||||
$regex.Replace($sql,('CREATE PROCEDURE [mixed_pilot].['+$name+']'),1)
|
||||
}
|
||||
try {
|
||||
Exec 'SET LOCK_TIMEOUT 2000; SET DEADLOCK_PRIORITY LOW;'
|
||||
$tx=$cn.BeginTransaction()
|
||||
Exec "DECLARE @r int; EXEC @r=sys.sp_getapplock @Resource='FT546.mixed_pilot.install',@LockMode='Exclusive',@LockOwner='Transaction',@LockTimeout=0; IF @r<0 THROW 51140,'Another pilot installation is in progress',1;"
|
||||
if((Scalar "SELECT OBJECT_ID('mixed_pilot.control','U')") -isnot [DBNull]){
|
||||
if((Scalar 'SELECT installed FROM mixed_pilot.control WHERE id=1') -ne 1){throw 'Previously uninstalled package: audit data retained; prepare a new reviewed installation rather than overwriting it'}
|
||||
if((Scalar 'SELECT version FROM mixed_pilot.control WHERE id=1') -ne '20260910-1'){throw 'Unsupported existing mixed-pilot version'}
|
||||
if((Scalar "SELECT COUNT(*) FROM mixed_pilot.module_backup WHERE installed_hash<>HASHBYTES('SHA2_256',OBJECT_DEFINITION(OBJECT_ID(object_name))) OR OBJECT_DEFINITION(OBJECT_ID(object_name)) IS NULL") -ne 0){throw 'Installed procedure drift detected; refusing overwrite'}
|
||||
if((Scalar "SELECT OBJECT_ID('mixed_pilot.installed_module','U')") -is [DBNull]){throw 'Missing installed-module manifest'}
|
||||
if((Scalar "SELECT COUNT(*) FROM mixed_pilot.installed_module WHERE definition_hash<>HASHBYTES('SHA2_256',OBJECT_DEFINITION(OBJECT_ID(object_name))) OR OBJECT_DEFINITION(OBJECT_ID(object_name)) IS NULL") -ne 0){throw 'Pilot helper/trigger drift detected; refusing to report success'}
|
||||
foreach($table in @('lane','admission','move_audit','dispatch_hold','module_backup')){
|
||||
if((Scalar "SELECT OBJECT_ID('mixed_pilot.$table','U')") -is [DBNull]){throw "Missing pilot table: $table"}
|
||||
}
|
||||
$tx.Commit();$tx.Dispose();$tx=$null;Write-Output 'ALREADY_INSTALLED: switches and data preserved';return
|
||||
}
|
||||
$expected=[ordered]@{
|
||||
proc_allot_space='DFF37262FD49271E9E6E657F01697731F1FF73DCD6DECA1F340107D560E26850'
|
||||
proc_yk_job_add='72708181352148DAF9490DD59FCBD0E3AC7A8C849EE8FC4AFCBFA0B1AF79AB5B'
|
||||
proc_yk_complete='C80CD445262BB483E7A7BDCF7DB97A44AD07A09D491869A60C094C5037621131'
|
||||
}
|
||||
$original=@{}
|
||||
foreach($name in $expected.Keys){
|
||||
$definition=Scalar "SELECT OBJECT_DEFINITION(OBJECT_ID('dbo.$name'))"
|
||||
if($definition -is [DBNull] -or (Hash $definition) -ne $expected[$name]){throw "Site procedure differs from reviewed baseline: $name. Nothing installed."}
|
||||
$original[$name]=$definition
|
||||
}
|
||||
# No stock/tasks/slot metadata are changed by installation. Admission remains OFF.
|
||||
Exec "IF SCHEMA_ID('mixed_pilot') IS NOT NULL THROW 51141,'Unexpected pre-existing mixed_pilot schema',1;"
|
||||
Exec 'CREATE SCHEMA mixed_pilot AUTHORIZATION dbo'
|
||||
foreach($batch in ([IO.File]::ReadAllText("$PSScriptRoot/objects.sql") -split '(?im)^\s*GO\s*\r?$')){if($batch.Trim()){Exec $batch}}
|
||||
foreach($name in $expected.Keys){
|
||||
$c=Cmd "INSERT mixed_pilot.module_backup(object_name,original_definition,original_hash,ansi_nulls,quoted_identifier) SELECT @name,definition,HASHBYTES('SHA2_256',definition),uses_ansi_nulls,uses_quoted_identifier FROM sys.sql_modules WHERE object_id=OBJECT_ID(@name)"
|
||||
[void]$c.Parameters.AddWithValue('@name',"dbo.$name");try{[void]$c.ExecuteNonQuery()}finally{$c.Dispose()}
|
||||
$ansi=Scalar "SELECT ansi_nulls FROM mixed_pilot.module_backup WHERE object_name='dbo.$name'"
|
||||
$quoted=Scalar "SELECT quoted_identifier FROM mixed_pilot.module_backup WHERE object_name='dbo.$name'"
|
||||
Exec (('SET ANSI_NULLS '+$(if($ansi){'ON'}else{'OFF'}))+'; SET QUOTED_IDENTIFIER '+$(if($quoted){'ON'}else{'OFF'})+';')
|
||||
Exec (RenameProc $original[$name] ('original_'+$name.Substring(5)))
|
||||
}
|
||||
Exec 'SET ANSI_NULLS ON; SET QUOTED_IDENTIFIER ON;'
|
||||
$inbound=RenameProc $original['proc_allot_space'] 'allot_space_with_fallback'
|
||||
$noSlot=[regex]::new("(?is)set\s+@v_result\s*=\s*2\s+set\s+@v_resultmsg\s*=\s*'[^']+'\s+RETURN")
|
||||
if($noSlot.Matches($inbound).Count -ne 2){throw 'Expected two Legacy no-position exits'}
|
||||
$inbound=$noSlot.Replace($inbound,[Text.RegularExpressions.MatchEvaluator]{param($match)
|
||||
'EXEC mixed_pilot.try_fallback @v_job_id,@v_cargo_space_id OUTPUT;'+"`r`n"+
|
||||
'IF @v_cargo_space_id IS NULL BEGIN '+$match.Value+' END;'+"`r`n"+
|
||||
'GOTO mixed_pilot_destination;'
|
||||
})
|
||||
$destination=[regex]::new('(?i)SELECT\s+@v_job_id\s*=\s*job_id\s+from\s+mydo_job\s+where\s+tray_code\s*=\s*@v_tray_code')
|
||||
if($destination.Matches($inbound).Count -ne 1){throw 'Expected one common Legacy destination block'}
|
||||
$inbound=$destination.Replace($inbound,@'
|
||||
mixed_pilot_destination:
|
||||
EXEC mixed_pilot.check_inbound @v_job_id,@v_cargo_space_id;
|
||||
SELECT @v_job_id = job_id from mydo_job where tray_code = @v_tray_code
|
||||
'@)
|
||||
Exec $inbound
|
||||
foreach($batch in ([IO.File]::ReadAllText("$PSScriptRoot/wrappers.sql") -split '(?im)^\s*GO\s*\r?$')){if($batch.Trim()){Exec $batch}}
|
||||
Exec "UPDATE mixed_pilot.module_backup SET installed_hash=HASHBYTES('SHA2_256',OBJECT_DEFINITION(OBJECT_ID(object_name)))"
|
||||
Exec "INSERT mixed_pilot.installed_module(object_name,definition_hash) SELECT QUOTENAME(OBJECT_SCHEMA_NAME(object_id))+'.'+QUOTENAME(OBJECT_NAME(object_id)),HASHBYTES('SHA2_256',definition) FROM sys.sql_modules WHERE OBJECT_SCHEMA_NAME(object_id)='mixed_pilot' OR OBJECT_NAME(object_id) IN ('tr_mixed_pilot_job_guard','tr_mixed_pilot_slot_release','tr_mixed_pilot_stock_release')"
|
||||
$tx.Commit();$tx.Dispose();$tx=$null
|
||||
Write-Output 'INSTALL_OK: pilot OFF; three C lanes configured but not enabled; Legacy definitions backed up in mixed_pilot.module_backup'
|
||||
} catch {
|
||||
if($tx){try{$tx.Rollback()}catch{};$tx.Dispose();$tx=$null}
|
||||
throw
|
||||
} finally {$cn.Dispose()}
|
||||
18
scripts/mixed-pilot/mixed-pilot.cmd
Normal file
18
scripts/mixed-pilot/mixed-pilot.cmd
Normal file
@@ -0,0 +1,18 @@
|
||||
@echo off
|
||||
setlocal
|
||||
if "%~3"=="" goto usage
|
||||
if /I "%~1"=="install" (
|
||||
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0install-mixed-pilot.ps1" -Server "%~2" -Database "%~3"
|
||||
exit /b
|
||||
)
|
||||
if /I "%~1"=="enable" (
|
||||
if /I not "%~4"=="CONFIRM-FIT-ALL-PROJECTS" goto usage
|
||||
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0control-mixed-pilot.ps1" -Server "%~2" -Database "%~3" -Mode enable -ConfirmPhysicalFit -AllProjectsMayMix
|
||||
exit /b
|
||||
)
|
||||
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0control-mixed-pilot.ps1" -Server "%~2" -Database "%~3" -Mode "%~1"
|
||||
exit /b
|
||||
:usage
|
||||
echo Usage: mixed-pilot.cmd install^|status^|disable^|uninstall "lpc:." "FT546-wms"
|
||||
echo Enable: mixed-pilot.cmd enable "lpc:." "FT546-wms" CONFIRM-FIT-ALL-PROJECTS
|
||||
exit /b 2
|
||||
256
scripts/mixed-pilot/objects.sql
Normal file
256
scripts/mixed-pilot/objects.sql
Normal file
@@ -0,0 +1,256 @@
|
||||
CREATE TABLE mixed_pilot.control (
|
||||
id int NOT NULL PRIMARY KEY CHECK(id=1),
|
||||
version varchar(30) NOT NULL,
|
||||
installed bit NOT NULL DEFAULT 1,
|
||||
enabled bit NOT NULL DEFAULT 0,
|
||||
all_projects_may_mix bit NOT NULL DEFAULT 0,
|
||||
min_free_e_slots int NOT NULL DEFAULT 2 CHECK(min_free_e_slots>=2)
|
||||
);
|
||||
INSERT mixed_pilot.control(id,version,enabled,all_projects_may_mix) VALUES(1,'20260910-1',0,0);
|
||||
CREATE TABLE mixed_pilot.lane (
|
||||
warehouse_id varchar(80) NOT NULL,
|
||||
lane_code varchar(80) NOT NULL,
|
||||
enabled bit NOT NULL DEFAULT 0,
|
||||
physical_fit_confirmed bit NOT NULL DEFAULT 0,
|
||||
protected bit NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(warehouse_id,lane_code)
|
||||
);
|
||||
INSERT mixed_pilot.lane(warehouse_id,lane_code) VALUES('01','C1203'),('01','C0204'),('01','C0205');
|
||||
CREATE TABLE mixed_pilot.module_backup (
|
||||
object_name sysname NOT NULL PRIMARY KEY,
|
||||
original_definition nvarchar(max) NOT NULL,
|
||||
original_hash varbinary(32) NOT NULL,
|
||||
installed_hash varbinary(32) NULL,
|
||||
ansi_nulls bit NOT NULL,
|
||||
quoted_identifier bit NOT NULL
|
||||
);
|
||||
CREATE TABLE mixed_pilot.installed_module (
|
||||
object_name nvarchar(260) NOT NULL PRIMARY KEY,
|
||||
definition_hash varbinary(32) NOT NULL
|
||||
);
|
||||
CREATE TABLE mixed_pilot.admission (
|
||||
job_id varchar(255) NOT NULL PRIMARY KEY,
|
||||
lane_code varchar(80) NOT NULL,
|
||||
cargo_space_id varchar(80) NOT NULL,
|
||||
tray_code varchar(255) NOT NULL,
|
||||
resident_project varchar(255) NOT NULL,
|
||||
guest_project varchar(255) NOT NULL,
|
||||
created_at datetime2 NOT NULL DEFAULT SYSUTCDATETIME()
|
||||
);
|
||||
CREATE TABLE mixed_pilot.move_audit (
|
||||
job_id varchar(255) NOT NULL PRIMARY KEY,
|
||||
source_slot varchar(255) NOT NULL,
|
||||
target_slot varchar(255) NOT NULL,
|
||||
tray_code varchar(255) NOT NULL,
|
||||
completed_at datetime2 NULL
|
||||
);
|
||||
CREATE TABLE mixed_pilot.dispatch_hold (
|
||||
job_id varchar(255) NOT NULL PRIMARY KEY,
|
||||
created_at datetime2 NOT NULL DEFAULT SYSUTCDATETIME(),
|
||||
released_at datetime2 NULL
|
||||
);
|
||||
GO
|
||||
-- A source position is inaccessible while ANY outer position has cargo or a live task.
|
||||
CREATE VIEW mixed_pilot.blocked_jobs AS
|
||||
SELECT j.job_id
|
||||
FROM dbo.mydo_job j
|
||||
JOIN dbo.bas_cargo_space s ON s.cargo_space_id=j.cargo_space_id
|
||||
JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no AND l.protected=1
|
||||
WHERE j.in_or_out>=20 AND EXISTS (
|
||||
SELECT 1 FROM dbo.bas_cargo_space b
|
||||
WHERE b.warehouse_id=s.warehouse_id AND b.group_coding_no=s.group_coding_no
|
||||
AND TRY_CONVERT(int,b.group_coding)>TRY_CONVERT(int,s.group_coding)
|
||||
AND (ISNULL(b.status,'')<>'1' OR ISNULL(b.locked,'')<>'N' OR NULLIF(b.tray_code,'') IS NOT NULL
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=b.cargo_space_id)
|
||||
OR EXISTS(SELECT 1 FROM dbo.mydo_job x WHERE x.cargo_space_id=b.cargo_space_id OR x.target_cargo_space_id=b.cargo_space_id))
|
||||
);
|
||||
GO
|
||||
CREATE PROCEDURE mixed_pilot.release_ready_jobs AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
-- Clearing only a slot flag is insufficient: inventory and front tasks must also be gone.
|
||||
UPDATE j SET status='1'
|
||||
FROM dbo.mydo_job j JOIN mixed_pilot.dispatch_hold h ON h.job_id=j.job_id AND h.released_at IS NULL
|
||||
JOIN dbo.bas_cargo_space s ON s.cargo_space_id=j.cargo_space_id
|
||||
WHERE j.status='8' AND s.status='4' AND s.tray_code=j.tray_code
|
||||
AND EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=s.cargo_space_id AND x.tray_code=j.tray_code)
|
||||
AND NOT EXISTS(SELECT 1 FROM mixed_pilot.blocked_jobs b WHERE b.job_id=j.job_id);
|
||||
UPDATE h SET released_at=SYSUTCDATETIME()
|
||||
FROM mixed_pilot.dispatch_hold h
|
||||
WHERE h.released_at IS NULL AND NOT EXISTS(SELECT 1 FROM dbo.mydo_job j WHERE j.job_id=h.job_id AND j.status='8');
|
||||
END;
|
||||
GO
|
||||
CREATE TRIGGER dbo.tr_mixed_pilot_job_guard ON dbo.mydo_job AFTER INSERT,UPDATE,DELETE AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
IF TRIGGER_NESTLEVEL(OBJECT_ID('dbo.tr_mixed_pilot_job_guard'))>1 RETURN;
|
||||
IF NOT EXISTS(SELECT 1 FROM mixed_pilot.lane WHERE enabled=1 OR protected=1) RETURN;
|
||||
-- Coordinate with inbound reservation before checking whether a lane has just become mixed.
|
||||
DECLARE @locked int;
|
||||
SELECT @locked=COUNT(*) FROM dbo.bas_cargo_space s WITH(UPDLOCK,HOLDLOCK)
|
||||
JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no
|
||||
WHERE (l.enabled=1 OR l.protected=1) AND EXISTS(
|
||||
SELECT 1 FROM inserted i JOIN dbo.bas_cargo_space origin ON origin.cargo_space_id=i.cargo_space_id
|
||||
WHERE origin.warehouse_id=s.warehouse_id AND origin.group_coding_no=s.group_coding_no AND i.in_or_out>=20);
|
||||
IF EXISTS(SELECT 1 FROM inserted i JOIN mixed_pilot.blocked_jobs blocked ON blocked.job_id=i.job_id
|
||||
JOIN dbo.bas_cargo_space s ON s.cargo_space_id=i.cargo_space_id
|
||||
JOIN dbo.bas_cargo_space front ON front.warehouse_id=s.warehouse_id AND front.group_coding_no=s.group_coding_no
|
||||
AND TRY_CONVERT(int,front.group_coding)>TRY_CONVERT(int,s.group_coding)
|
||||
WHERE i.status='1' AND NOT EXISTS(SELECT 1 FROM dbo.mydo_job j WITH(UPDLOCK,HOLDLOCK)
|
||||
WHERE j.cargo_space_id=front.cargo_space_id AND j.tray_code=front.tray_code AND j.in_or_out>=20 AND j.status IN('1','3')))
|
||||
THROW 51126,N'外侧尚未安排腾挪,请使用自动分配出库;外侧正在入库时请稍后重试。',1;
|
||||
INSERT mixed_pilot.dispatch_hold(job_id)
|
||||
SELECT DISTINCT i.job_id FROM inserted i JOIN mixed_pilot.blocked_jobs b ON b.job_id=i.job_id
|
||||
WHERE i.status='1' AND NOT EXISTS(SELECT 1 FROM mixed_pilot.dispatch_hold h WITH(UPDLOCK,HOLDLOCK) WHERE h.job_id=i.job_id);
|
||||
UPDATE j SET status='8'
|
||||
FROM dbo.mydo_job j JOIN inserted i ON i.job_id=j.job_id
|
||||
JOIN mixed_pilot.blocked_jobs b ON b.job_id=j.job_id
|
||||
WHERE j.status='1';
|
||||
EXEC mixed_pilot.release_ready_jobs;
|
||||
END;
|
||||
GO
|
||||
CREATE TRIGGER dbo.tr_mixed_pilot_slot_release ON dbo.bas_cargo_space AFTER UPDATE AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
IF NOT EXISTS(SELECT 1 FROM inserted i JOIN mixed_pilot.lane l ON l.warehouse_id=i.warehouse_id AND l.lane_code=i.group_coding_no AND l.protected=1) RETURN;
|
||||
EXEC mixed_pilot.release_ready_jobs;
|
||||
END;
|
||||
GO
|
||||
CREATE TRIGGER dbo.tr_mixed_pilot_stock_release ON dbo.bas_stock AFTER UPDATE,DELETE AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
IF NOT EXISTS(SELECT 1 FROM deleted d JOIN dbo.bas_cargo_space s ON s.cargo_space_id=d.cargo_space_id
|
||||
JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no AND l.protected=1) RETURN;
|
||||
EXEC mixed_pilot.release_ready_jobs;
|
||||
END;
|
||||
GO
|
||||
CREATE PROCEDURE mixed_pilot.check_inbound
|
||||
@job_id varchar(255),@slot varchar(80)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
IF @@TRANCOUNT=0 OR ISNULL(APPLOCK_MODE('public','FT546.mixed_pilot.inbound','Transaction'),'NoLock')<>'Exclusive'
|
||||
THROW 51129,N'试点入库校验必须由受保护的分配入口调用。',1;
|
||||
DECLARE @lane varchar(80),@warehouse varchar(80),@depth int,@project varchar(255),@tray varchar(255),@resident varchar(255),@rear varchar(80),@floor int;
|
||||
SELECT @lane=s.group_coding_no,@warehouse=s.warehouse_id,@depth=TRY_CONVERT(int,s.group_coding),@floor=s.the_floor
|
||||
FROM dbo.bas_cargo_space s JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no
|
||||
WHERE s.cargo_space_id=@slot AND (l.enabled=1 OR l.protected=1);
|
||||
IF @lane IS NULL RETURN;
|
||||
SELECT @project=line_id,@tray=tray_code FROM dbo.mydo_job WHERE job_id=@job_id AND warehouse_id=@warehouse AND in_or_out<20;
|
||||
IF @project IS NULL OR @project IN ('','999') OR @tray IS NULL
|
||||
THROW 51101,N'试点货位的任务项目或仓库不明确,请核对入库任务。',1;
|
||||
IF (SELECT COUNT(*) FROM dbo.bas_cargo_space WHERE warehouse_id=@warehouse AND group_coding_no=@lane)<>2
|
||||
OR (SELECT COUNT(*) FROM dbo.bas_cargo_space WHERE warehouse_id=@warehouse AND group_coding_no=@lane AND TRY_CONVERT(int,group_coding)=1)<>1
|
||||
OR (SELECT COUNT(*) FROM dbo.bas_cargo_space WHERE warehouse_id=@warehouse AND group_coding_no=@lane AND TRY_CONVERT(int,group_coding)=2)<>1
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE warehouse_id=@warehouse AND group_coding_no=@lane AND (this_cargo_area<>'C' OR TRY_CONVERT(int,group_coding) NOT IN(1,2) OR TRY_CONVERT(int,group_coding) IS NULL))
|
||||
THROW 51102,N'试点仅支持已指定的双深C区通道。',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE cargo_space_id=@slot AND status='1' AND locked='N' AND NULLIF(tray_code,'') IS NULL)
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_stock WHERE cargo_space_id=@slot)
|
||||
OR EXISTS(SELECT 1 FROM dbo.mydo_job j JOIN dbo.bas_cargo_space s ON s.cargo_space_id=j.cargo_space_id OR s.cargo_space_id=j.target_cargo_space_id
|
||||
WHERE s.warehouse_id=@warehouse AND s.group_coding_no=@lane AND j.job_id<>@job_id)
|
||||
THROW 51103,N'试点通道已有库存或在途任务,请稍后重试。',1;
|
||||
IF EXISTS(SELECT 1 FROM dbo.bas_cargo_space s WHERE s.warehouse_id=@warehouse AND s.group_coding_no=@lane
|
||||
AND (s.locked<>'N' OR s.status NOT IN('1','2')))
|
||||
THROW 51104,N'试点通道正在作业或被锁定,请稍后重试。',1;
|
||||
IF @depth=1 AND EXISTS(SELECT 1 FROM dbo.bas_cargo_space s WHERE s.warehouse_id=@warehouse AND s.group_coding_no=@lane AND TRY_CONVERT(int,s.group_coding)=2
|
||||
AND (s.status<>'1' OR NULLIF(s.tray_code,'') IS NOT NULL OR EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=s.cargo_space_id)))
|
||||
THROW 51105,N'外侧尚有托盘,不能向通道内侧补货。',1;
|
||||
SELECT @rear=cargo_space_id FROM dbo.bas_cargo_space WHERE warehouse_id=@warehouse AND group_coding_no=@lane AND TRY_CONVERT(int,group_coding)=1;
|
||||
IF @depth<>2 OR NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE cargo_space_id=@rear AND status='2') RETURN;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job_detail WHERE job_id=@job_id)
|
||||
OR EXISTS(SELECT 1 FROM dbo.mydo_job_detail WHERE job_id=@job_id AND (NULLIF(spare4,'') IS NULL OR spare4<>@project))
|
||||
OR (SELECT COUNT(DISTINCT spare4) FROM dbo.bas_stock WHERE cargo_space_id=@rear)<>1
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_stock x JOIN dbo.bas_cargo_space s ON s.cargo_space_id=x.cargo_space_id
|
||||
WHERE x.cargo_space_id=@rear AND (NULLIF(x.spare4,'') IS NULL OR x.tray_code<>s.tray_code OR x.tray_code IS NULL OR ISNULL(x.out_quantity,0)<>0))
|
||||
THROW 51106,N'托盘项目明细不一致或库存已分配,不能参与混放。',1;
|
||||
SELECT TOP(1) @resident=spare4 FROM dbo.bas_stock WHERE cargo_space_id=@rear;
|
||||
IF @resident=@project RETURN;
|
||||
IF @resident='999' OR NOT EXISTS(SELECT 1 FROM mixed_pilot.control WHERE id=1 AND enabled=1 AND all_projects_may_mix=1)
|
||||
OR NOT EXISTS(SELECT 1 FROM mixed_pilot.lane WHERE warehouse_id=@warehouse AND lane_code=@lane AND enabled=1 AND physical_fit_confirmed=1)
|
||||
THROW 51107,N'该通道未开放混放或尚未确认托盘适配。',1;
|
||||
IF (SELECT COUNT(*) FROM dbo.bas_cargo_space e WHERE e.warehouse_id=@warehouse AND e.the_floor=@floor AND e.this_cargo_area='E'
|
||||
AND e.status='1' AND e.locked='N' AND NULLIF(e.tray_code,'') IS NULL
|
||||
AND NOT EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=e.cargo_space_id)
|
||||
AND NOT EXISTS(SELECT 1 FROM dbo.mydo_job x WHERE x.cargo_space_id=e.cargo_space_id OR x.target_cargo_space_id=e.cargo_space_id)
|
||||
AND NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space b WHERE b.warehouse_id=e.warehouse_id AND b.group_coding_no=e.group_coding_no
|
||||
AND TRY_CONVERT(int,b.group_coding)>TRY_CONVERT(int,e.group_coding)
|
||||
AND (b.status<>'1' OR b.locked<>'N' OR NULLIF(b.tray_code,'') IS NOT NULL
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=b.cargo_space_id)
|
||||
OR EXISTS(SELECT 1 FROM dbo.mydo_job x WHERE x.cargo_space_id=b.cargo_space_id OR x.target_cargo_space_id=b.cargo_space_id))))
|
||||
<(SELECT min_free_e_slots FROM mixed_pilot.control WHERE id=1)
|
||||
THROW 51108,N'同层可用移库空间不足,暂停新增混放。',1;
|
||||
UPDATE mixed_pilot.lane SET protected=1 WHERE warehouse_id=@warehouse AND lane_code=@lane;
|
||||
IF EXISTS(SELECT 1 FROM mixed_pilot.admission WHERE job_id=@job_id AND (cargo_space_id<>@slot OR tray_code<>@tray OR resident_project<>@resident OR guest_project<>@project))
|
||||
THROW 51127,N'该任务已有不同的混放记录,请核对后重新创建任务。',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM mixed_pilot.admission WHERE job_id=@job_id)
|
||||
INSERT mixed_pilot.admission(job_id,lane_code,cargo_space_id,tray_code,resident_project,guest_project)
|
||||
VALUES(@job_id,@lane,@slot,@tray,@resident,@project);
|
||||
END;
|
||||
GO
|
||||
CREATE PROCEDURE mixed_pilot.try_fallback
|
||||
@job_id varchar(255),@slot varchar(80) OUTPUT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET @slot=NULL;
|
||||
IF NOT EXISTS(SELECT 1 FROM mixed_pilot.control WHERE id=1 AND enabled=1 AND all_projects_may_mix=1) RETURN;
|
||||
DECLARE @candidate varchar(80),@last_error nvarchar(2048);
|
||||
DECLARE candidates CURSOR LOCAL FAST_FORWARD FOR
|
||||
SELECT s.cargo_space_id FROM dbo.bas_cargo_space s
|
||||
JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no
|
||||
JOIN dbo.mydo_job j ON j.job_id=@job_id AND j.warehouse_id=s.warehouse_id
|
||||
WHERE l.enabled=1 AND l.physical_fit_confirmed=1 AND s.this_cargo_area='C' AND TRY_CONVERT(int,s.group_coding)=2
|
||||
AND s.status='1' AND s.locked='N' AND NULLIF(s.tray_code,'') IS NULL
|
||||
AND EXISTS(SELECT 1 FROM dbo.bas_cargo_space r WHERE r.warehouse_id=s.warehouse_id AND r.group_coding_no=s.group_coding_no AND TRY_CONVERT(int,r.group_coding)=1 AND r.status='2')
|
||||
AND NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space busy WHERE busy.warehouse_id=s.warehouse_id AND busy.the_floor=s.the_floor AND busy.status='3')
|
||||
ORDER BY s.the_floor,s.group_coding_no;
|
||||
OPEN candidates; FETCH NEXT FROM candidates INTO @candidate;
|
||||
WHILE @@FETCH_STATUS=0
|
||||
BEGIN
|
||||
BEGIN TRY
|
||||
EXEC mixed_pilot.check_inbound @job_id,@candidate;
|
||||
SET @slot=@candidate;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF ERROR_NUMBER() NOT BETWEEN 51101 AND 51108 BEGIN CLOSE candidates; DEALLOCATE candidates; THROW; END;
|
||||
SET @last_error=ERROR_MESSAGE();
|
||||
END CATCH;
|
||||
IF @slot IS NOT NULL BREAK;
|
||||
FETCH NEXT FROM candidates INTO @candidate;
|
||||
END;
|
||||
CLOSE candidates; DEALLOCATE candidates;
|
||||
IF @slot IS NULL AND @last_error IS NOT NULL THROW 51128,@last_error,1;
|
||||
END;
|
||||
GO
|
||||
CREATE PROCEDURE mixed_pilot.check_move
|
||||
@source varchar(255),@target varchar(255),@tray varchar(255)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
DECLARE @floor int,@warehouse varchar(80),@src_lane varchar(80),@src_depth int,@dst_lane varchar(80),@dst_depth int,@dummy int;
|
||||
-- Lock both positions before the Legacy routine changes source/target or creates a task.
|
||||
SELECT @dummy=COUNT(*) FROM dbo.bas_cargo_space WITH(UPDLOCK,HOLDLOCK) WHERE cargo_space_id IN(@source,@target);
|
||||
SELECT @floor=the_floor,@warehouse=warehouse_id,@src_lane=group_coding_no,@src_depth=TRY_CONVERT(int,group_coding)
|
||||
FROM dbo.bas_cargo_space WHERE cargo_space_id=@source AND status='2' AND locked='N' AND tray_code=@tray;
|
||||
IF @floor IS NULL OR @source=@target OR NULLIF(@tray,'') IS NULL
|
||||
THROW 51111,N'移库起点、托盘或货位状态不一致,未创建移库任务。',1;
|
||||
IF EXISTS(SELECT 1 FROM dbo.mydo_job WITH(UPDLOCK,HOLDLOCK) WHERE tray_code=@tray OR cargo_space_id IN(@source,@target) OR target_cargo_space_id IN(@source,@target))
|
||||
THROW 51112,N'移库起终点已有任务,未重复创建移库。',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.bas_stock WITH(UPDLOCK,HOLDLOCK) WHERE cargo_space_id=@source AND tray_code=@tray)
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_stock WITH(UPDLOCK,HOLDLOCK) WHERE cargo_space_id=@source AND (tray_code<>@tray OR tray_code IS NULL OR ISNULL(out_quantity,0)<>0))
|
||||
THROW 51113,N'移库源库存不一致或已分配,未创建移库任务。',1;
|
||||
SELECT @dst_lane=group_coding_no,@dst_depth=TRY_CONVERT(int,group_coding)
|
||||
FROM dbo.bas_cargo_space WHERE cargo_space_id=@target AND warehouse_id=@warehouse AND the_floor=@floor
|
||||
AND this_cargo_area='E' AND status='1' AND locked='N' AND NULLIF(tray_code,'') IS NULL;
|
||||
IF @dst_lane IS NULL OR @dst_depth IS NULL OR EXISTS(SELECT 1 FROM dbo.bas_stock WITH(UPDLOCK,HOLDLOCK) WHERE cargo_space_id=@target)
|
||||
THROW 51114,N'同层移库目标不可用,未创建移库任务。',1;
|
||||
SELECT @dummy=COUNT(*) FROM dbo.bas_cargo_space WITH(UPDLOCK,HOLDLOCK)
|
||||
WHERE warehouse_id=@warehouse AND group_coding_no IN(@src_lane,@dst_lane);
|
||||
IF EXISTS(SELECT 1 FROM dbo.bas_cargo_space b WITH(UPDLOCK,HOLDLOCK) WHERE b.warehouse_id=@warehouse
|
||||
AND ((b.group_coding_no=@src_lane AND TRY_CONVERT(int,b.group_coding)>@src_depth)
|
||||
OR (b.group_coding_no=@dst_lane AND TRY_CONVERT(int,b.group_coding)>@dst_depth))
|
||||
AND (b.status<>'1' OR b.locked<>'N' OR NULLIF(b.tray_code,'') IS NOT NULL
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_stock x WITH(UPDLOCK,HOLDLOCK) WHERE x.cargo_space_id=b.cargo_space_id)
|
||||
OR EXISTS(SELECT 1 FROM dbo.mydo_job x WITH(UPDLOCK,HOLDLOCK) WHERE x.cargo_space_id=b.cargo_space_id OR x.target_cargo_space_id=b.cargo_space_id)))
|
||||
THROW 51115,N'移库路径外侧被占用,未创建移库任务。',1;
|
||||
END;
|
||||
121
scripts/mixed-pilot/test-mixed-pilot.ps1
Normal file
121
scripts/mixed-pilot/test-mixed-pilot.ps1
Normal file
@@ -0,0 +1,121 @@
|
||||
param(
|
||||
[string]$Server = 'lpc:.',
|
||||
[Parameter(Mandatory=$true)][string]$FixtureJson,
|
||||
[switch]$Baseline
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
|
||||
# Synthetic rows only. Never run fixtures in a production database.
|
||||
$testDb = 'FT546WMS_MixedPilot_Test_' + [Guid]::NewGuid().ToString('N')
|
||||
$cn = [Data.SqlClient.SqlConnection]::new("Server=$Server;Database=master;Integrated Security=True;Connect Timeout=8;Application Name=FT546MixedPilotTests")
|
||||
$cn.Open()
|
||||
function Exec([string]$sql) {
|
||||
$c=$cn.CreateCommand(); $c.CommandTimeout=15
|
||||
$c.CommandText=$sql
|
||||
try { [void]$c.ExecuteNonQuery() } finally {$c.Dispose()}
|
||||
}
|
||||
function Scalar([string]$sql) {
|
||||
$c=$cn.CreateCommand(); $c.CommandTimeout=15; $c.CommandText=$sql
|
||||
try { $c.ExecuteScalar() } finally {$c.Dispose()}
|
||||
}
|
||||
function BatchFile([string]$file) {
|
||||
foreach($batch in ([IO.File]::ReadAllText($file) -split '(?im)^\s*GO\s*\r?$')) {
|
||||
if($batch.Trim()) { Exec $batch }
|
||||
}
|
||||
}
|
||||
function Assert([bool]$ok,[string]$name) { if(-not $ok){throw "FAIL: $name"}; Write-Output "PASS: $name" }
|
||||
$created=$false
|
||||
try {
|
||||
Exec "CREATE DATABASE [$testDb] ON PRIMARY (NAME=N'$testDb', FILENAME=N'$(Scalar "SELECT CONVERT(nvarchar(4000),SERVERPROPERTY('InstanceDefaultDataPath'))")$testDb.mdf',SIZE=8MB,MAXSIZE=64MB,FILEGROWTH=8MB) LOG ON (NAME=N'${testDb}_log', FILENAME=N'$(Scalar "SELECT CONVERT(nvarchar(4000),SERVERPROPERTY('InstanceDefaultLogPath'))")${testDb}_log.ldf',SIZE=8MB,MAXSIZE=64MB,FILEGROWTH=8MB)"
|
||||
$created=$true; $cn.ChangeDatabase($testDb)
|
||||
Exec 'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET DEADLOCK_PRIORITY LOW;'
|
||||
$fixture=Get-Content -LiteralPath $FixtureJson -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$allowed=@('bas_cargo_space','bas_stock','mydo_job','mydo_job_detail','mydo_job_finish','bas_led','move_msg','out_stockout','out_stockout_detail')
|
||||
foreach($table in ($fixture.columns|Where-Object table_name -in $allowed|Group-Object table_name)) {
|
||||
$defs=@(foreach($c in $table.Group){
|
||||
$type=$c.type_name
|
||||
if($type -in @('varchar','char','nvarchar','nchar','varbinary','binary')) {
|
||||
$size=if($c.max_length -eq -1){'max'}elseif($type.StartsWith('n')){[string]($c.max_length/2)}else{[string]$c.max_length}
|
||||
$type+="($size)"
|
||||
} elseif($type -in @('decimal','numeric')) {$type+="($($c.precision),$($c.scale))"}
|
||||
# Nullability is relaxed for concise synthetic fixtures, types/order match site.
|
||||
"[$($c.column_name)] $type NULL"
|
||||
})
|
||||
Exec "CREATE TABLE dbo.[$($table.Name)] ($($defs -join ','))"
|
||||
}
|
||||
foreach($p in $fixture.wms_objects|Where-Object name -in @('cargo_space_group_count','proc_allot_space','proc_yk_job_add','proc_yk_complete','proc_out_auto_allocation','proc_out_hand_allocation')) {Exec $p.definition}
|
||||
Exec "CREATE TABLE dbo.bas_roadway(roadway_id varchar(80),out_locked varchar(10),out_locked2 varchar(10))"
|
||||
Exec "CREATE PROCEDURE dbo.util_serial_job @kind varchar(10),@id varchar(255) OUTPUT AS SET @id=CONVERT(varchar(36),NEWID())"
|
||||
Exec "CREATE SEQUENCE dbo.test_task_seq AS int START WITH 900001"
|
||||
Exec "CREATE PROCEDURE dbo.util_serial_task @kind varchar(10),@id int OUTPUT AS SET @id=NEXT VALUE FOR dbo.test_task_seq"
|
||||
if(-not $Baseline){ & "$PSScriptRoot/install-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase }
|
||||
BatchFile "$PSScriptRoot/tests/seed.sql"
|
||||
if($Baseline){
|
||||
$result=Scalar "DECLARE @r int,@m varchar(255); EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT; SELECT @r"
|
||||
Assert ($result -eq 10) 'new project gets designated front slot when Legacy has no position'
|
||||
} else {
|
||||
& "$PSScriptRoot/control-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase -Mode enable -ConfirmPhysicalFit -AllProjectsMayMix
|
||||
Assert ((Scalar 'SELECT COUNT(*) FROM mixed_pilot.lane WHERE enabled=1') -eq 3) 'enable command opens exactly three whole channels'
|
||||
& "$PSScriptRoot/control-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase -Mode disable
|
||||
$refused=$false
|
||||
try { & "$PSScriptRoot/control-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase -Mode uninstall } catch {$refused=$_.Exception.Message -like '*not drained*'}
|
||||
Assert $refused 'uninstall refuses occupied trial channels'
|
||||
BatchFile "$PSScriptRoot/tests/behavior.sql"
|
||||
Write-Output 'PASS: mixed-pilot SQL behavior suite'
|
||||
Exec 'DELETE dbo.mydo_job; DELETE dbo.mydo_job_detail; DELETE dbo.bas_stock; DELETE dbo.bas_cargo_space; DELETE mixed_pilot.dispatch_hold; DELETE mixed_pilot.move_audit; DELETE mixed_pilot.admission;'
|
||||
BatchFile "$PSScriptRoot/tests/seed.sql"
|
||||
BatchFile "$PSScriptRoot/tests/legacy-outbound.sql"
|
||||
Write-Output 'PASS: actual site Legacy outbound procedure generates blocker relocation and holds rear outbound'
|
||||
Exec 'DELETE dbo.mydo_job; DELETE dbo.mydo_job_detail; DELETE dbo.bas_stock; DELETE dbo.bas_cargo_space; DELETE mixed_pilot.dispatch_hold; DELETE mixed_pilot.move_audit; DELETE mixed_pilot.admission;'
|
||||
BatchFile "$PSScriptRoot/tests/seed.sql"
|
||||
Exec "UPDATE mixed_pilot.control SET enabled=1,all_projects_may_mix=1; UPDATE mixed_pilot.lane SET enabled=CASE WHEN lane_code='C1203' THEN 1 ELSE 0 END,physical_fit_confirmed=1; INSERT dbo.mydo_job(job_id,tray_code,warehouse_id,line_id,in_or_out,status,fault_flag) VALUES('IN-RACE','RACE-TRAY','01','RACE',1,'1','0'); INSERT dbo.mydo_job_detail(job_id,spare4,spare1,spare2,spare3,in_quantity) VALUES('IN-RACE','RACE','X','Y','Z',1)"
|
||||
$raceCn=[Data.SqlClient.SqlConnection]::new("Server=$Server;Database=$testDb;Integrated Security=True;Connect Timeout=8;Application Name=FT546MixedPilotRaceTest")
|
||||
$raceCn.Open()
|
||||
try {
|
||||
Exec 'BEGIN TRANSACTION;'
|
||||
Exec "DECLARE @r int,@m varchar(255); EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT; IF @r<>10 THROW 51950,'First racing allocation failed',1;"
|
||||
$raceCmd=$raceCn.CreateCommand();$raceCmd.CommandTimeout=10
|
||||
$raceCmd.CommandText="DECLARE @r int,@m varchar(255); EXEC dbo.proc_allot_space 'RACE-TRAY','TEST',@r OUTPUT,@m OUTPUT; SELECT @r;"
|
||||
$pending=$raceCmd.ExecuteScalarAsync()
|
||||
# Hold the first reservation across the second request's lock timeout.
|
||||
$second=$pending.GetAwaiter().GetResult()
|
||||
Assert ($second -ne 10) 'concurrent request cannot allocate a reserved pilot slot'
|
||||
Exec 'COMMIT TRANSACTION;'
|
||||
Assert ((Scalar "SELECT COUNT(*) FROM dbo.mydo_job WHERE cargo_space_id='120703'") -eq 1) 'only one inbound task owns the pilot slot'
|
||||
} finally {
|
||||
if((Scalar 'SELECT @@TRANCOUNT') -gt 0){Exec 'ROLLBACK TRANSACTION;'}
|
||||
if($raceCmd){$raceCmd.Dispose()};$raceCn.Dispose()
|
||||
}
|
||||
# Re-running install must preserve runtime switches and history.
|
||||
Exec 'UPDATE mixed_pilot.control SET enabled=1 WHERE id=1'
|
||||
& "$PSScriptRoot/install-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase
|
||||
Assert ((Scalar 'SELECT enabled FROM mixed_pilot.control WHERE id=1') -eq 1) 'repeat install preserves switch'
|
||||
$releaseDefinition=Scalar "SELECT OBJECT_DEFINITION(OBJECT_ID('mixed_pilot.release_ready_jobs'))"
|
||||
Exec 'ALTER PROCEDURE mixed_pilot.release_ready_jobs AS RETURN;'
|
||||
$driftRejected=$false
|
||||
try { & "$PSScriptRoot/install-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase } catch {$driftRejected=$_.Exception.Message -like '*drift*'}
|
||||
Assert $driftRejected 'repeat install detects altered pilot helpers'
|
||||
Exec 'DROP PROCEDURE mixed_pilot.release_ready_jobs;'
|
||||
Exec $releaseDefinition
|
||||
& "$PSScriptRoot/control-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase -Mode disable
|
||||
Exec 'DELETE dbo.mydo_job; DELETE dbo.bas_stock; UPDATE dbo.bas_cargo_space SET status=''1'',tray_code=NULL;'
|
||||
& "$PSScriptRoot/control-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase -Mode uninstall
|
||||
Assert ((Scalar 'SELECT installed FROM mixed_pilot.control WHERE id=1') -eq 0) 'uninstall removes runtime protection only after draining'
|
||||
foreach($p in $fixture.wms_objects|Where-Object name -in @('proc_allot_space','proc_yk_job_add','proc_yk_complete')) {
|
||||
$restored=Scalar "SELECT OBJECT_DEFINITION(OBJECT_ID('dbo.$($p.name)'))"
|
||||
# SQL Server may persist CREATE even when ALTER was submitted. Compare the full body with only that keyword normalized.
|
||||
$expected=[regex]::Replace($p.definition,'(?i)\b(?:CREATE|ALTER)\s+PROCEDURE','CREATE PROCEDURE')
|
||||
$normalized=[regex]::Replace($restored,'(?i)\b(?:CREATE|ALTER)\s+PROCEDURE','CREATE PROCEDURE')
|
||||
Assert ($normalized -ceq $expected) "uninstall restores $($p.name) body exactly"
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if($created){
|
||||
[Data.SqlClient.SqlConnection]::ClearAllPools()
|
||||
$cn.ChangeDatabase('master')
|
||||
if($testDb -notmatch '^FT546WMS_MixedPilot_Test_[a-f0-9]{32}$'){throw 'Invalid scratch database name'}
|
||||
Exec "DROP DATABASE [$testDb]"
|
||||
Write-Output "Scratch database removed: $testDb"
|
||||
}
|
||||
$cn.Dispose()
|
||||
}
|
||||
138
scripts/mixed-pilot/tests/behavior.sql
Normal file
138
scripts/mixed-pilot/tests/behavior.sql
Normal file
@@ -0,0 +1,138 @@
|
||||
SET NOCOUNT ON;
|
||||
DECLARE @r int,@m varchar(255),@job varchar(255),@n int;
|
||||
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
|
||||
IF @r<>2 OR EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='IN-NEW' AND cargo_space_id IS NOT NULL)
|
||||
THROW 51900,'Disabled pilot must preserve Legacy no-position behavior',1;
|
||||
|
||||
UPDATE mixed_pilot.control SET enabled=1,all_projects_may_mix=1 WHERE id=1;
|
||||
UPDATE mixed_pilot.lane SET enabled=1,physical_fit_confirmed=1 WHERE lane_code='C1203';
|
||||
-- An ordinary same-project position must win before mixed fallback.
|
||||
INSERT dbo.bas_cargo_space(cargo_space_id,warehouse_id,the_floor,the_platoon,the_column,group_coding_no,group_coding,this_cargo_area,status,locked,tray_code)
|
||||
VALUES('110801','01',1,11,8,'C1101','1','C','2','N','SAME'),('110701','01',1,11,7,'C1101','2','C','1','N',NULL);
|
||||
INSERT dbo.bas_stock(stock_id,cargo_space_id,tray_code,spare4,spare1,spare2,spare3,quantity,out_quantity)
|
||||
VALUES('SAME-S','110801','SAME','NEW','D','E','F',1,0);
|
||||
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
|
||||
IF @r<>10 OR NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='IN-NEW' AND cargo_space_id='110701')
|
||||
THROW 51901,'Normal Legacy allocation must have priority',1;
|
||||
UPDATE dbo.mydo_job SET cargo_space_id=NULL,status='1' WHERE job_id='IN-NEW';
|
||||
DELETE dbo.bas_stock WHERE stock_id='SAME-S';
|
||||
DELETE dbo.bas_cargo_space WHERE group_coding_no='C1101';
|
||||
|
||||
-- No physical confirmation, missing E capacity, ambiguity and common-project input fail closed.
|
||||
UPDATE mixed_pilot.lane SET physical_fit_confirmed=0 WHERE lane_code='C1203';
|
||||
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
|
||||
IF @r=10 THROW 51902,'Unconfirmed physical fit must not admit mixed pallet',1;
|
||||
UPDATE mixed_pilot.lane SET physical_fit_confirmed=1 WHERE lane_code='C1203';
|
||||
UPDATE dbo.bas_cargo_space SET locked='Y' WHERE this_cargo_area='E';
|
||||
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
|
||||
IF @r=10 THROW 51903,'No same-floor E capacity must not admit mixed pallet',1;
|
||||
UPDATE dbo.bas_cargo_space SET locked='N' WHERE this_cargo_area='E';
|
||||
UPDATE dbo.mydo_job_detail SET spare4='WRONG' WHERE job_id='IN-NEW';
|
||||
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
|
||||
IF @r=10 THROW 51904,'Header/detail project mismatch must be rejected',1;
|
||||
UPDATE dbo.mydo_job_detail SET spare4='NEW' WHERE job_id='IN-NEW';
|
||||
|
||||
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
|
||||
IF @r<>10 OR NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='IN-NEW' AND cargo_space_id='120703' AND status='3')
|
||||
THROW 51905,'Fallback must reserve only the designated outer position',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE cargo_space_id='120703' AND status='3')
|
||||
THROW 51906,'Fallback must reserve slot and job together',1;
|
||||
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
|
||||
IF @r<>10 OR (SELECT COUNT(*) FROM mixed_pilot.admission)<>1
|
||||
THROW 51907,'Repeated allocation must return same reservation without a second admission',1;
|
||||
|
||||
INSERT dbo.mydo_job(job_id,tray_code,warehouse_id,line_id,in_or_out,status,execution_phase,fault_flag)
|
||||
VALUES('IN-THIRD','THIRD-TRAY','01','THIRD',1,'1','0','0');
|
||||
INSERT dbo.mydo_job_detail(job_detail_id,job_id,spare4,spare1,spare2,spare3,in_quantity)
|
||||
VALUES('IN-THIRD-1','IN-THIRD','THIRD','X','Y','Z',1);
|
||||
EXEC dbo.proc_allot_space 'THIRD-TRAY','TEST',@r OUTPUT,@m OUTPUT;
|
||||
IF @r=10 THROW 51908,'A third project or an already reserved front slot must be rejected',1;
|
||||
|
||||
-- Simulate a confirmed physical inbound completion (no WCS or HTTP is contacted).
|
||||
INSERT dbo.bas_stock(stock_id,cargo_space_id,tray_code,spare4,spare1,spare2,spare3,quantity,out_quantity)
|
||||
VALUES('GUEST-S','120703','NEW-TRAY','NEW','D','E','F',1,0);
|
||||
UPDATE dbo.bas_cargo_space SET status='2',tray_code='NEW-TRAY' WHERE cargo_space_id='120703';
|
||||
DELETE dbo.mydo_job WHERE job_id='IN-NEW';
|
||||
|
||||
-- Automatic allocation creates the blocker move before the rear job.
|
||||
EXEC dbo.proc_yk_job_add '120703','130803','NEW-TRAY';
|
||||
SELECT @job=job_id FROM dbo.mydo_job WHERE in_or_out=30 AND tray_code='NEW-TRAY';
|
||||
UPDATE dbo.bas_cargo_space SET status='4' WHERE cargo_space_id='120803';
|
||||
INSERT dbo.mydo_job(job_id,tray_code,cargo_space_id,class_id,in_or_out,status,execution_phase,task_no)
|
||||
VALUES('OUT-REAR','RESIDENT','120803','3',21,'1','0',800001);
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-REAR' AND status='8')
|
||||
THROW 51909,'Blocked rear outbound must be held before the WMS scheduler can see it',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM mixed_pilot.dispatch_hold WHERE job_id='OUT-REAR' AND released_at IS NULL)
|
||||
THROW 51910,'Held job must be diagnosable',1;
|
||||
|
||||
-- Occupied or wrong-floor destinations must not create a move or change inventory.
|
||||
BEGIN TRY
|
||||
EXEC dbo.proc_yk_job_add '120703','020704','NEW-TRAY';
|
||||
THROW 51911,'Wrong-floor move was accepted',1;
|
||||
END TRY BEGIN CATCH IF ERROR_NUMBER()=51911 THROW; END CATCH;
|
||||
IF (SELECT COUNT(*) FROM dbo.mydo_job WHERE in_or_out=30)<>1
|
||||
THROW 51912,'Rejected relocation must leave no partial task',1;
|
||||
|
||||
EXEC dbo.proc_yk_job_add '120703','130803','NEW-TRAY';
|
||||
IF @job IS NULL OR NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id=@job AND status='1')
|
||||
THROW 51913,'Outer relocation must remain dispatchable',1;
|
||||
EXEC dbo.proc_yk_job_add '120703','130803','NEW-TRAY';
|
||||
IF (SELECT COUNT(*) FROM dbo.mydo_job WHERE in_or_out=30)<>1
|
||||
THROW 51914,'Repeated identical relocation must not duplicate tasks',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-REAR' AND status='8')
|
||||
THROW 51915,'Creating a relocation is not physical completion',1;
|
||||
|
||||
-- Switch OFF while pallets are mixed: no new admissions, protection must remain.
|
||||
UPDATE mixed_pilot.control SET enabled=0 WHERE id=1;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-REAR' AND status='8')
|
||||
THROW 51916,'Disabling admission must not release blocked outbound',1;
|
||||
EXEC dbo.proc_yk_complete @job,@r OUTPUT;
|
||||
IF @r<>10 OR NOT EXISTS(SELECT 1 FROM dbo.bas_stock WHERE stock_id='GUEST-S' AND cargo_space_id='130803' AND spare4='NEW')
|
||||
THROW 51917,'Move completion must preserve guest project ownership',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-REAR' AND status='1')
|
||||
THROW 51918,'Rear outbound must be released only after confirmed front clearance',1;
|
||||
SELECT @n=COUNT(*) FROM dbo.move_msg;
|
||||
EXEC dbo.proc_yk_complete @job,@r OUTPUT;
|
||||
IF @r<>10 OR (SELECT COUNT(*) FROM dbo.move_msg)<>@n
|
||||
THROW 51919,'Duplicate move completion must not change stock or emit duplicate move messages',1;
|
||||
|
||||
-- Ordinary front outbound also blocks the rear until BOTH stock and job are cleared.
|
||||
DELETE dbo.mydo_job WHERE job_id='OUT-REAR';
|
||||
UPDATE dbo.bas_stock SET cargo_space_id='120703' WHERE stock_id='GUEST-S';
|
||||
UPDATE dbo.bas_cargo_space SET status='2',tray_code='NEW-TRAY' WHERE cargo_space_id='120703';
|
||||
INSERT dbo.mydo_job(job_id,tray_code,cargo_space_id,class_id,in_or_out,status,execution_phase,task_no)
|
||||
VALUES('OUT-FRONT','NEW-TRAY','120703','3',21,'1','0',800002),('OUT-REAR2','RESIDENT','120803','3',21,'1','0',800003);
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-FRONT' AND status='1') OR NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-REAR2' AND status='8')
|
||||
THROW 51920,'Front may dispatch while blocked rear must wait',1;
|
||||
UPDATE dbo.bas_cargo_space SET status='1',tray_code=NULL WHERE cargo_space_id='120703';
|
||||
DELETE dbo.bas_stock WHERE stock_id='GUEST-S';
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-REAR2' AND status='8')
|
||||
THROW 51921,'An active front task must continue to block rear dispatch',1;
|
||||
DELETE dbo.mydo_job WHERE job_id='OUT-FRONT';
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-REAR2' AND status='1')
|
||||
THROW 51922,'Final front task deletion must release waiting rear job',1;
|
||||
|
||||
-- Unlisted lanes must not be held by the pilot triggers.
|
||||
INSERT dbo.mydo_job(job_id,tray_code,cargo_space_id,class_id,in_or_out,status,execution_phase,task_no)
|
||||
VALUES('OTHER-OUT','OTHER-RESIDENT','080801','1',21,'1','0',800004);
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OTHER-OUT' AND status='1')
|
||||
THROW 51923,'Unlisted lanes must retain Legacy dispatch behavior',1;
|
||||
-- Manual allocation must not strand a held task when no blocker move exists.
|
||||
DELETE dbo.mydo_job WHERE job_id='OUT-REAR2';
|
||||
UPDATE dbo.bas_cargo_space SET status='2',tray_code='NEW-TRAY' WHERE cargo_space_id='120703';
|
||||
INSERT dbo.bas_stock(stock_id,cargo_space_id,tray_code,spare4,quantity,out_quantity)
|
||||
VALUES('GUEST-AGAIN','120703','NEW-TRAY','NEW',1,0);
|
||||
BEGIN TRY
|
||||
INSERT dbo.mydo_job(job_id,tray_code,cargo_space_id,class_id,in_or_out,status,execution_phase,task_no)
|
||||
VALUES('MANUAL-BLOCKED','RESIDENT','120803','3',21,'1','0',800005);
|
||||
THROW 51924,'Manual blocked outbound was accepted without a blocker task',1;
|
||||
END TRY BEGIN CATCH IF ERROR_NUMBER()=51924 THROW; END CATCH;
|
||||
IF EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='MANUAL-BLOCKED')
|
||||
THROW 51925,'Rejected manual allocation must not leave a stranded held job',1;
|
||||
BEGIN TRY
|
||||
EXEC dbo.proc_yk_job_add '080801','130803','OTHER-RESIDENT';
|
||||
THROW 51926,'Unlisted source overwrote a protected relocation destination',1;
|
||||
END TRY BEGIN CATCH IF ERROR_NUMBER()=51926 THROW; END CATCH;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE cargo_space_id='130803' AND status='2' AND tray_code='NEW-TRAY')
|
||||
THROW 51927,'Rejected stale relocation target must retain existing occupancy',1;
|
||||
PRINT 'PASS: mixed-pilot sequential behavior assertions';
|
||||
29
scripts/mixed-pilot/tests/legacy-outbound.sql
Normal file
29
scripts/mixed-pilot/tests/legacy-outbound.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
SET NOCOUNT ON;
|
||||
UPDATE mixed_pilot.control SET enabled=1,all_projects_may_mix=1;
|
||||
UPDATE mixed_pilot.lane SET enabled=CASE WHEN lane_code='C1203' THEN 1 ELSE 0 END,protected=1,physical_fit_confirmed=1;
|
||||
DECLARE @r int,@m varchar(255),@outResult varchar(100),@move varchar(255),@rear varchar(255);
|
||||
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
|
||||
IF @r<>10 THROW 51930,'Failed to prepare genuine Legacy outbound fixture',1;
|
||||
INSERT dbo.bas_stock(stock_id,cargo_space_id,tray_code,spare4,spare1,spare2,spare3,product_id,quantity,out_quantity,in_date)
|
||||
VALUES('GUEST-S','120703','NEW-TRAY','NEW','D','E','F','P-GUEST',1,0,GETDATE());
|
||||
UPDATE dbo.bas_cargo_space SET status='2',tray_code='NEW-TRAY' WHERE cargo_space_id='120703';
|
||||
DELETE dbo.mydo_job WHERE job_id='IN-NEW';
|
||||
UPDATE dbo.bas_stock SET product_id='P-REAR',in_date=GETDATE() WHERE stock_id='S1';
|
||||
INSERT dbo.out_stockout(stockout_id,warehouse_id,stockout_source,fid,spare6,spare8,out_type)
|
||||
VALUES('ORDER1','01','0','1','1','2','21');
|
||||
INSERT dbo.out_stockout_detail(stockout_id,stockout_detail_id,product_id,spare1,spare2,spare3,spare4,quantity,allot_quantity)
|
||||
VALUES('ORDER1','ORDER1-1','P-REAR','A','B','C','OLD',1,0);
|
||||
EXEC dbo.proc_out_auto_allocation 'ORDER1-1',@outResult OUTPUT,@m OUTPUT;
|
||||
IF @outResult<>'10' BEGIN DECLARE @failure nvarchar(2048)=N'Legacy auto allocation failed: '+ISNULL(@m,'NULL'); THROW 51931,@failure,1; END;
|
||||
SELECT @move=job_id FROM dbo.mydo_job WHERE in_or_out=30 AND tray_code='NEW-TRAY';
|
||||
SELECT @rear=job_id FROM dbo.mydo_job WHERE in_or_out=21 AND tray_code='RESIDENT';
|
||||
IF @move IS NULL OR @rear IS NULL THROW 51932,'Actual Legacy must create both correct tray tasks',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id=@move AND cargo_space_id='120703' AND status='1')
|
||||
THROW 51933,'Legacy must move the outer guest, not the resident',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id=@rear AND cargo_space_id='120803' AND status='8')
|
||||
THROW 51934,'Legacy rear task must be invisible to the status=1 dispatch scheduler',1;
|
||||
EXEC dbo.proc_yk_complete @move,@r OUTPUT;
|
||||
IF @r<>10 OR NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id=@rear AND status='1')
|
||||
THROW 51935,'Actual Legacy relocation completion must release the rear task',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.bas_stock WHERE stock_id='S1' AND spare4='OLD' AND out_quantity=1)
|
||||
THROW 51936,'Resident allocation and project must retain Legacy values',1;
|
||||
22
scripts/mixed-pilot/tests/seed.sql
Normal file
22
scripts/mixed-pilot/tests/seed.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
INSERT dbo.bas_cargo_space(cargo_space_id,warehouse_id,the_floor,the_platoon,the_column,group_coding_no,group_coding,this_cargo_area,status,locked,tray_code)
|
||||
VALUES ('120803','01',3,12,8,'C1203','1','C','2','N','RESIDENT'),
|
||||
('120703','01',3,12,7,'C1203','2','C','1','N',NULL),
|
||||
('020804','01',4,2,8,'C0204','1','C','2','N','RESIDENT4'),
|
||||
('020704','01',4,2,7,'C0204','2','C','1','N',NULL),
|
||||
('020805','01',5,2,8,'C0205','1','C','2','N','RESIDENT5'),
|
||||
('020705','01',5,2,7,'C0205','2','C','1','N',NULL),
|
||||
('130803','01',3,13,8,'C1303','1','E','1','N',NULL),
|
||||
('130703','01',3,13,7,'C1303','2','E','1','N',NULL),
|
||||
('140803','01',3,14,8,'C1403','1','E','1','N',NULL),
|
||||
('140703','01',3,14,7,'C1403','2','E','1','N',NULL),
|
||||
('080801','01',1,8,8,'C0801','1','C','2','N','OTHER-RESIDENT'),
|
||||
('080701','01',1,8,7,'C0801','2','C','1','N',NULL);
|
||||
INSERT dbo.bas_stock(stock_id,cargo_space_id,tray_code,spare4,spare1,spare2,spare3,quantity,out_quantity)
|
||||
VALUES ('S1','120803','RESIDENT','OLD','A','B','C',10,0),
|
||||
('S4','020804','RESIDENT4','OLD4','A','B','C',10,0),
|
||||
('S5','020805','RESIDENT5','OLD5','A','B','C',10,0),
|
||||
('SX','080801','OTHER-RESIDENT','OLD-X','A','B','C',10,0);
|
||||
INSERT dbo.mydo_job(job_id,tray_code,warehouse_id,line_id,in_or_out,status,execution_phase,fault_flag)
|
||||
VALUES ('IN-NEW','NEW-TRAY','01','NEW',1,'1','0','0');
|
||||
INSERT dbo.mydo_job_detail(job_detail_id,job_id,spare4,spare1,spare2,spare3,product_id,in_quantity)
|
||||
VALUES ('IN-NEW-1','IN-NEW','NEW','D','E','F','P1',1);
|
||||
112
scripts/mixed-pilot/wrappers.sql
Normal file
112
scripts/mixed-pilot/wrappers.sql
Normal file
@@ -0,0 +1,112 @@
|
||||
ALTER PROCEDURE dbo.proc_allot_space @v_tray_code varchar(80),@v_code varchar(80),@v_result int OUTPUT,@v_resultmsg varchar(255) OUTPUT AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
IF NOT EXISTS(SELECT 1 FROM mixed_pilot.control WHERE enabled=1) AND NOT EXISTS(SELECT 1 FROM mixed_pilot.lane WHERE protected=1)
|
||||
BEGIN
|
||||
EXEC mixed_pilot.original_allot_space @v_tray_code,@v_code,@v_result OUTPUT,@v_resultmsg OUTPUT;
|
||||
RETURN;
|
||||
END;
|
||||
SET XACT_ABORT OFF;
|
||||
DECLARE @own bit=CASE WHEN @@TRANCOUNT=0 THEN 1 ELSE 0 END,@lock int,@dummy int,@job varchar(255),@slot varchar(80);
|
||||
IF @own=1 BEGIN TRANSACTION; ELSE SAVE TRANSACTION mp_inbound;
|
||||
BEGIN TRY
|
||||
EXEC @lock=sys.sp_getapplock @Resource='FT546.mixed_pilot.inbound',@LockMode='Exclusive',@LockOwner='Transaction',@LockTimeout=500;
|
||||
IF @lock<0 THROW 51120,N'试点分配正在处理,请稍后重试。',1;
|
||||
SELECT @dummy=COUNT(*) FROM dbo.bas_cargo_space s WITH(UPDLOCK,HOLDLOCK)
|
||||
JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no WHERE l.enabled=1 OR l.protected=1;
|
||||
SELECT @dummy=COUNT(*) FROM dbo.mydo_job WITH(UPDLOCK,HOLDLOCK) WHERE tray_code=@v_tray_code;
|
||||
IF @dummy<>1 THROW 51121,N'托盘任务不存在或不唯一,请核对后重试。',1;
|
||||
SELECT @job=job_id,@slot=NULLIF(cargo_space_id,'') FROM dbo.mydo_job WHERE tray_code=@v_tray_code AND in_or_out<20;
|
||||
IF @job IS NULL THROW 51122,N'没有对应的入库任务。',1;
|
||||
IF @slot IS NOT NULL
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE cargo_space_id=@slot AND status='3')
|
||||
THROW 51123,N'已有入库分配的货位状态不一致,请核对任务。',1;
|
||||
SET @v_result=10; SET @v_resultmsg='成功';
|
||||
END
|
||||
ELSE EXEC mixed_pilot.allot_space_with_fallback @v_tray_code,@v_code,@v_result OUTPUT,@v_resultmsg OUTPUT;
|
||||
IF @own=1 COMMIT TRANSACTION;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF @own=1 AND XACT_STATE()<>0 ROLLBACK TRANSACTION;
|
||||
ELSE IF XACT_STATE()=1 ROLLBACK TRANSACTION mp_inbound;
|
||||
ELSE IF XACT_STATE()=-1 THROW;
|
||||
SET @v_result=2; SET @v_resultmsg=CONVERT(varchar(255),ERROR_MESSAGE());
|
||||
END CATCH;
|
||||
END;
|
||||
GO
|
||||
ALTER PROCEDURE dbo.proc_yk_job_add @v_cargo_space_id varchar(255),@v_tar_cargo_space_id varchar(255),@v_tray_code varchar(255) AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
IF NOT EXISTS(SELECT 1 FROM mixed_pilot.lane WHERE enabled=1 OR protected=1)
|
||||
BEGIN
|
||||
EXEC mixed_pilot.original_yk_job_add @v_cargo_space_id,@v_tar_cargo_space_id,@v_tray_code;
|
||||
RETURN;
|
||||
END;
|
||||
DECLARE @own bit=CASE WHEN @@TRANCOUNT=0 THEN 1 ELSE 0 END,@job varchar(255),@dummy int;
|
||||
IF @own=1 BEGIN TRANSACTION; ELSE SAVE TRANSACTION mp_move;
|
||||
BEGIN TRY
|
||||
SELECT @dummy=COUNT(*) FROM dbo.bas_cargo_space WITH(UPDLOCK,HOLDLOCK) WHERE cargo_space_id IN(@v_cargo_space_id,@v_tar_cargo_space_id);
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space s JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no
|
||||
WHERE s.cargo_space_id IN(@v_cargo_space_id,@v_tar_cargo_space_id) AND (l.protected=1 OR l.enabled=1))
|
||||
AND NOT EXISTS(SELECT 1 FROM mixed_pilot.move_audit WHERE target_slot=@v_tar_cargo_space_id)
|
||||
BEGIN
|
||||
EXEC mixed_pilot.original_yk_job_add @v_cargo_space_id,@v_tar_cargo_space_id,@v_tray_code;
|
||||
IF @own=1 COMMIT TRANSACTION;
|
||||
RETURN;
|
||||
END;
|
||||
IF EXISTS(SELECT 1 FROM mixed_pilot.move_audit a JOIN dbo.mydo_job j ON j.job_id=a.job_id
|
||||
WHERE a.source_slot=@v_cargo_space_id AND a.target_slot=@v_tar_cargo_space_id AND a.tray_code=@v_tray_code AND a.completed_at IS NULL
|
||||
AND j.in_or_out=30 AND j.cargo_space_id=a.source_slot AND j.target_cargo_space_id=a.target_slot AND j.tray_code=a.tray_code)
|
||||
BEGIN
|
||||
IF @own=1 COMMIT TRANSACTION;
|
||||
RETURN;
|
||||
END;
|
||||
EXEC mixed_pilot.check_move @v_cargo_space_id,@v_tar_cargo_space_id,@v_tray_code;
|
||||
EXEC mixed_pilot.original_yk_job_add @v_cargo_space_id,@v_tar_cargo_space_id,@v_tray_code;
|
||||
SELECT @job=job_id FROM dbo.mydo_job WHERE in_or_out=30 AND cargo_space_id=@v_cargo_space_id AND target_cargo_space_id=@v_tar_cargo_space_id AND tray_code=@v_tray_code;
|
||||
IF @job IS NULL THROW 51124,N'未能建立可核验的移库任务。',1;
|
||||
INSERT mixed_pilot.move_audit(job_id,source_slot,target_slot,tray_code) VALUES(@job,@v_cargo_space_id,@v_tar_cargo_space_id,@v_tray_code);
|
||||
IF @own=1 COMMIT TRANSACTION;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF @own=1 AND XACT_STATE()<>0 ROLLBACK TRANSACTION;
|
||||
ELSE IF XACT_STATE()=1 ROLLBACK TRANSACTION mp_move;
|
||||
THROW;
|
||||
END CATCH;
|
||||
END;
|
||||
GO
|
||||
ALTER PROCEDURE dbo.proc_yk_complete @v_job_id varchar(255),@v_result int OUTPUT AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
IF NOT EXISTS(SELECT 1 FROM mixed_pilot.move_audit WHERE job_id=@v_job_id)
|
||||
BEGIN
|
||||
EXEC mixed_pilot.original_yk_complete @v_job_id,@v_result OUTPUT;
|
||||
RETURN;
|
||||
END;
|
||||
DECLARE @own bit=CASE WHEN @@TRANCOUNT=0 THEN 1 ELSE 0 END,@src varchar(255),@dst varchar(255),@tray varchar(255),@done datetime2,@dummy int;
|
||||
IF @own=1 BEGIN TRANSACTION; ELSE SAVE TRANSACTION mp_complete;
|
||||
BEGIN TRY
|
||||
SELECT @src=source_slot,@dst=target_slot,@tray=tray_code,@done=completed_at FROM mixed_pilot.move_audit WITH(UPDLOCK,HOLDLOCK) WHERE job_id=@v_job_id;
|
||||
IF @done IS NULL
|
||||
BEGIN
|
||||
SELECT @dummy=COUNT(*) FROM dbo.bas_cargo_space WITH(UPDLOCK,HOLDLOCK) WHERE cargo_space_id IN(@src,@dst);
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WITH(UPDLOCK,HOLDLOCK) WHERE job_id=@v_job_id AND in_or_out=30 AND tray_code=@tray AND cargo_space_id=@src AND target_cargo_space_id=@dst)
|
||||
OR NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE cargo_space_id=@src AND status='4' AND tray_code=@tray)
|
||||
OR NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE cargo_space_id=@dst AND status='3' AND NULLIF(tray_code,'') IS NULL)
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_stock WHERE cargo_space_id=@dst)
|
||||
OR NOT EXISTS(SELECT 1 FROM dbo.bas_stock WHERE cargo_space_id=@src AND tray_code=@tray)
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_stock WHERE cargo_space_id=@src AND (tray_code<>@tray OR tray_code IS NULL))
|
||||
THROW 51125,N'移库完成的起终点或库存不一致,未修改库存。',1;
|
||||
EXEC mixed_pilot.original_yk_complete @v_job_id,@v_result OUTPUT;
|
||||
UPDATE mixed_pilot.move_audit SET completed_at=SYSUTCDATETIME() WHERE job_id=@v_job_id;
|
||||
END;
|
||||
SET @v_result=10;
|
||||
IF @own=1 COMMIT TRANSACTION;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF @own=1 AND XACT_STATE()<>0 ROLLBACK TRANSACTION;
|
||||
ELSE IF XACT_STATE()=1 ROLLBACK TRANSACTION mp_complete;
|
||||
THROW;
|
||||
END CATCH;
|
||||
END;
|
||||
57
scripts/shadow-lane-lock/README.md
Normal file
57
scripts/shadow-lane-lock/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 影子通道汇总锁定值修复(2026-09-14)
|
||||
|
||||
现场 `locked=N` 表示未锁定;旧 `alg.usp_rebuild_lane_state` 仅接受 NULL/空白,导致通道可用数量归零。补丁让它与现有货位视图一致,接受 NULL、空白、`0`、`N`。其他占用、库存、角色、预留、出库与空洞限制全部保留。两个完整安装 SQL 也同步修正,避免以后安装重新引入问题。
|
||||
|
||||
## 对正在运行的业务有何影响
|
||||
|
||||
影子读取生产 `dbo` 数据,生成 `alg.*` 快照,再规划和记录比较结果。此次只 ALTER `alg.usp_rebuild_lane_state`,其运行结果写入 `alg.lane_state`。不修改生产货位的锁定值、货位状态、库存、订单或 Legacy 分配过程,不切换 ACTIVE,不需要替换 JAR 或重启 WMS/WCS。
|
||||
|
||||
现有三通道混放试点是另外一套已批准的业务规则,本补丁不更改其范围或开关。共用 SQL Server 仍会共享 CPU、I/O、内存及锁资源,不能承诺性能影响为零;补丁使用 1.5 秒锁等待上限,等待失败退出,不结束其他会话。
|
||||
|
||||
## 从 CMD 操作
|
||||
|
||||
在本目录执行,使用 Windows 集成认证;账号需要读取过程定义及 ALTER 此过程的权限。默认数据库仅支持 `FT546-wms`。
|
||||
|
||||
```bat
|
||||
repair-shadow-lane-lock.cmd -Mode Check
|
||||
```
|
||||
|
||||
记录返回的 `sha256`。确认状态为 `NEEDS_FIX` 后,将下面的占位文字换成该值;备份文件使用全新路径(不能覆盖已有文件):
|
||||
|
||||
```bat
|
||||
repair-shadow-lane-lock.cmd -Mode Apply -ExpectedSha256 检查返回的sha256 -BackupFile "C:\project\FT546WMS-shadow-lane-lock-20260914\original-procedure.json"
|
||||
```
|
||||
|
||||
返回 `status=OK` 后,等待现有影子流程在业务退出后的安静窗口正常刷新;它不是固定每隔几分钟必刷。不要手动重建正在使用的快照,也不要为此运行完整 `shadow-v3-install.cmd`。补丁不改整包版本标识,用过程哈希确认状态。`ALREADY_FIXED` 表示无需重复更新;hash mismatch 表示现场定义有变化,应先核对。锁超时或失败时事务自动回滚,已有备份保留;重试采用新的备份文件名。
|
||||
|
||||
只读检查(SQL 查询窗口):
|
||||
|
||||
```sql
|
||||
SELECT snapshot_generation,snapshot_status,synchronized_at,last_error
|
||||
FROM alg.shadow_snapshot_state WHERE warehouse_id='01';
|
||||
SELECT COUNT(*) candidate_lanes,SUM(l.available_count) aggregated_available_spaces
|
||||
FROM alg.lane_state l JOIN alg.shadow_snapshot_state p
|
||||
ON p.warehouse_id=l.warehouse_id AND p.snapshot_generation=l.snapshot_generation
|
||||
WHERE l.warehouse_id='01' AND p.snapshot_status='PUBLISHED'
|
||||
AND l.available_count>0 AND l.pending_outbound=0 AND l.has_gap=0;
|
||||
```
|
||||
|
||||
候选通道恢复不代表任意托盘一定能分配,也不代表完整算法可以接管。仍需新发生的真实入库/出库比较和混放移库闭环证据。
|
||||
|
||||
## 回退
|
||||
|
||||
重新 `Check`,将返回的当前 `sha256` 填入命令,使用 Apply 保存的原始备份。只恢复此过程原文及 ANSI/quoted 设置;有后续改动时会拒绝覆盖:
|
||||
|
||||
```bat
|
||||
repair-shadow-lane-lock.cmd -Mode Rollback -ExpectedSha256 当前sha256 -BackupFile "C:\project\FT546WMS-shadow-lane-lock-20260914\original-procedure.json"
|
||||
```
|
||||
|
||||
回退后下一次正常刷新会恢复旧汇总行为,包括旧的 `N` 误排除问题。既有影子记录不删除,业务数据不回退。
|
||||
|
||||
## 隔离回归
|
||||
|
||||
```bat
|
||||
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts\shadow-lane-lock\test-shadow-lane-lock.ps1 -TestRepair
|
||||
```
|
||||
|
||||
从项目根目录执行。自动创建随机名称 `FT546WMS_LaneLock_Test_<GUID>` 的小型测试库,只放人工数据;不会接收生产库作为测试目标。执行两个源 SQL 中真实的 rebuild 过程,指纹计算使用明确标注的测试桩。覆盖锁值、各排除规则、跨代次、跨仓库/通道、重复刷新、修复的精确范围、哈希拒绝、重复更新、DDL 失败回滚及恢复原文。退出时删除本次测试库;此测试不验证现场托盘的实际规划效果。
|
||||
22
scripts/shadow-lane-lock/assertions.sql
Normal file
22
scripts/shadow-lane-lock/assertions.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
IF NOT EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='UNLOCK_N' AND available_count=1)
|
||||
THROW 51950,'locked=N must be available in the actual lane aggregate',1;
|
||||
IF (SELECT COUNT(*) FROM alg.lane_state WHERE warehouse_id='01' AND lane_code LIKE 'UNLOCK_%' AND available_count=1 AND pending_outbound=0 AND has_gap=0 AND snapshot_generation=101)<>7
|
||||
THROW 51950,'NULL, blank, 0, N, trimmed N, released reservation and old generation must remain eligible',1;
|
||||
IF EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='01' AND lane_code NOT LIKE 'UNLOCK_%' AND available_count<>0)
|
||||
THROW 51950,'locked/unknown/occupied/tray/stock/role/reservation/outbound/gap exclusions must remain',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='OUTBOUND' AND pending_outbound=1 AND available_count=0)
|
||||
THROW 51950,'outbound must exclude the entire lane',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='GAP' AND has_gap=1 AND available_count=0)
|
||||
THROW 51950,'gap must exclude the entire lane',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='UNLOCK_N' AND space_count=1 AND occupied_count=0)
|
||||
THROW 51950,'old generation must not pollute current counts',1;
|
||||
DECLARE @stamp datetime2=(SELECT rebuilt_at FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='UNLOCK_N');
|
||||
EXEC alg.usp_rebuild_lane_state @warehouse_id='01';
|
||||
IF (SELECT rebuilt_at FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='UNLOCK_N')<>@stamp
|
||||
THROW 51950,'unchanged repeat rebuild should not rewrite lane state',1;
|
||||
EXEC alg.usp_rebuild_lane_state @warehouse_id='02';
|
||||
EXEC alg.usp_rebuild_lane_state @warehouse_id='01',@lane_code='UNLOCK_N';
|
||||
IF NOT EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='02' AND lane_code='OTHER_WAREHOUSE' AND available_count=1 AND snapshot_generation=201)
|
||||
THROW 51950,'scoped rebuild must preserve other warehouse',1;
|
||||
IF (SELECT COUNT(*) FROM alg.lane_state WHERE warehouse_id='01')<>16
|
||||
THROW 51950,'scoped rebuild must preserve other lanes',1;
|
||||
43
scripts/shadow-lane-lock/fixture.sql
Normal file
43
scripts/shadow-lane-lock/fixture.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
-- Only synthetic fixtures; this script is run exclusively in a randomly named scratch DB.
|
||||
CREATE TABLE alg.cargo_space_snapshot(warehouse_id varchar(80),space_id varchar(80),lane_code varchar(80),depth_raw varchar(10),space_status varchar(10),locked varchar(10),tray_code varchar(80),snapshot_generation bigint);
|
||||
CREATE TABLE alg.space_inventory_summary(warehouse_id varchar(80),space_id varchar(80),stock_detail_count int,snapshot_generation bigint);
|
||||
CREATE TABLE alg.space_role(space_id varchar(80),role varchar(40));
|
||||
CREATE TABLE alg.active_job_snapshot(warehouse_id varchar(80),source_space_id varchar(80),in_or_out varchar(10),snapshot_generation bigint);
|
||||
CREATE TABLE alg.reservation(reservation_id varchar(80),space_id varchar(80),status varchar(20));
|
||||
CREATE TABLE alg.shadow_snapshot_state(warehouse_id varchar(80),snapshot_generation bigint,snapshot_status varchar(20));
|
||||
CREATE TABLE alg.lane_state(warehouse_id varchar(80),lane_code varchar(80),space_count int,occupied_count int,available_count int,pending_outbound bit,has_gap bit,semantic_fingerprint varbinary(32),snapshot_generation bigint,rebuilt_at datetime2 DEFAULT SYSUTCDATETIME(),PRIMARY KEY(warehouse_id,lane_code));
|
||||
GO
|
||||
-- Explicit test stub: fingerprint internals are outside this aggregation regression.
|
||||
-- The actual rebuild procedure, cursor, guards and MERGE are always executed unmodified.
|
||||
CREATE PROCEDURE alg.usp_compute_lane_semantic_fingerprint
|
||||
@warehouse_id varchar(80),@lane_code varchar(80),@semantic_fingerprint varbinary(32) OUTPUT,
|
||||
@inventory_source varchar(20),@snapshot_generation bigint
|
||||
AS
|
||||
SET @semantic_fingerprint=HASHBYTES('SHA2_256',@warehouse_id+':'+@lane_code+':'+CONVERT(varchar(30),@snapshot_generation));
|
||||
GO
|
||||
INSERT alg.shadow_snapshot_state VALUES('01',101,'PUBLISHED'),('02',201,'PUBLISHED');
|
||||
INSERT alg.cargo_space_snapshot VALUES
|
||||
('01','N','UNLOCK_N','1','1','N',NULL,101),
|
||||
('01','ZERO','UNLOCK_ZERO','1','1','0',NULL,101),
|
||||
('01','NULL','UNLOCK_NULL','1','1',NULL,NULL,101),
|
||||
('01','BLANK','UNLOCK_BLANK','1','1',' ',NULL,101),
|
||||
('01','TRIM','UNLOCK_TRIM','1','1',' N ',NULL,101),
|
||||
('01','Y','LOCK_Y','1','1','Y',NULL,101),
|
||||
('01','UNKNOWN','LOCK_UNKNOWN','1','1','?',NULL,101),
|
||||
('01','STATUS','OCCUPIED','1','2','N',NULL,101),
|
||||
('01','TRAY','HAS_TRAY','1','1','N','T1',101),
|
||||
('01','STOCK','HAS_STOCK','1','1','N',NULL,101),
|
||||
('01','ROLE','PROTECTED','1','1','N',NULL,101),
|
||||
('01','RESERVED','RESERVED','1','1','N',NULL,101),
|
||||
('01','RELEASED','UNLOCK_RELEASED','1','1','N',NULL,101),
|
||||
('01','OUT1','OUTBOUND','1','1','N',NULL,101),
|
||||
('01','OUT2','OUTBOUND','2','1','N',NULL,101),
|
||||
('01','GAP1','GAP','1','1','N',NULL,101),
|
||||
('01','GAP2','GAP','2','2','N','T2',101),
|
||||
('01','HISTORY','UNLOCK_HISTORY','1','1','N',NULL,101),
|
||||
('01','N','UNLOCK_N','1','2','Y','OLD',100),
|
||||
('02','W2','OTHER_WAREHOUSE','1','1','N',NULL,201);
|
||||
INSERT alg.space_inventory_summary VALUES('01','STOCK',1,101),('01','HISTORY',1,100);
|
||||
INSERT alg.space_role VALUES('ROLE','BUFFER');
|
||||
INSERT alg.reservation VALUES('R1','RESERVED','ACTIVE'),('R2','RELEASED','RELEASED');
|
||||
INSERT alg.active_job_snapshot VALUES('01','OUT1','20',101),('01','HISTORY','20',100);
|
||||
3
scripts/shadow-lane-lock/repair-shadow-lane-lock.cmd
Normal file
3
scripts/shadow-lane-lock/repair-shadow-lane-lock.cmd
Normal file
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0repair-shadow-lane-lock.ps1" %*
|
||||
exit /b %errorlevel%
|
||||
136
scripts/shadow-lane-lock/repair-shadow-lane-lock.ps1
Normal file
136
scripts/shadow-lane-lock/repair-shadow-lane-lock.ps1
Normal file
@@ -0,0 +1,136 @@
|
||||
param(
|
||||
[string]$Server = 'lpc:.',
|
||||
[string]$Database = 'FT546-wms',
|
||||
[ValidateSet('Check','Apply','Rollback')][string]$Mode = 'Check',
|
||||
[string]$ExpectedSha256,
|
||||
[string]$BackupFile
|
||||
)
|
||||
$ErrorActionPreference='Stop'
|
||||
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
|
||||
if($Database -ne 'FT546-wms' -and $Database -notmatch '^FT546WMS_LaneLock_Test_[a-f0-9]{32}$') {
|
||||
throw 'Only FT546-wms or the dedicated scratch database is supported'
|
||||
}
|
||||
if($Mode -ne 'Check' -and ($ExpectedSha256 -notmatch '^[a-fA-F0-9]{64}$' -or -not $BackupFile)) {
|
||||
throw 'Apply/Rollback requires ExpectedSha256 and BackupFile'
|
||||
}
|
||||
$old="NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL"
|
||||
$new="(NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL OR LTRIM(RTRIM(cargo.locked)) IN ('0', 'N'))"
|
||||
function Hash([string]$value) {
|
||||
$sha=[Security.Cryptography.SHA256]::Create()
|
||||
try { ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($value)))).Replace('-','').ToLowerInvariant() }
|
||||
finally { $sha.Dispose() }
|
||||
}
|
||||
function Canonical([string]$value) {
|
||||
if($value -notmatch '(?i)\A\s*(CREATE|ALTER)\s+PROCEDURE\s+alg\.usp_rebuild_lane_state\b') {
|
||||
throw 'Unexpected procedure header'
|
||||
}
|
||||
[regex]::Replace($value,'(?i)\A\s*(CREATE|ALTER)\s+PROCEDURE','ALTER PROCEDURE')
|
||||
}
|
||||
$cs=[Data.SqlClient.SqlConnectionStringBuilder]::new()
|
||||
$cs['Data Source']=$Server; $cs['Initial Catalog']=$Database; $cs['Integrated Security']=$true
|
||||
$cs['Connect Timeout']=8; $cs['Application Name']='FT546ShadowLaneLockRepair'
|
||||
$cn=[Data.SqlClient.SqlConnection]::new($cs.ConnectionString)
|
||||
$transaction=$null
|
||||
function Command([string]$sql) {
|
||||
$cmd=$cn.CreateCommand(); $cmd.CommandTimeout=10; $cmd.CommandText=$sql
|
||||
if($script:transaction){$cmd.Transaction=$script:transaction}
|
||||
return $cmd
|
||||
}
|
||||
function Exec([string]$sql) {
|
||||
$cmd=Command $sql
|
||||
try { [void]$cmd.ExecuteNonQuery() } finally { $cmd.Dispose() }
|
||||
}
|
||||
function Scalar([string]$sql) {
|
||||
$cmd=Command $sql
|
||||
try { $cmd.ExecuteScalar() } finally { $cmd.Dispose() }
|
||||
}
|
||||
function ReadDefinition {
|
||||
$cmd=Command @'
|
||||
SELECT m.definition,m.uses_ansi_nulls,m.uses_quoted_identifier
|
||||
FROM sys.sql_modules m JOIN sys.procedures p ON p.object_id=m.object_id
|
||||
WHERE p.object_id=OBJECT_ID(N'alg.usp_rebuild_lane_state',N'P');
|
||||
'@
|
||||
try {
|
||||
$reader=$cmd.ExecuteReader()
|
||||
try {
|
||||
if(-not $reader.Read() -or $reader.IsDBNull(0)){throw 'Procedure missing or encrypted'}
|
||||
[pscustomobject]@{definition=$reader.GetString(0);ansi=$reader.GetBoolean(1);quoted=$reader.GetBoolean(2)}
|
||||
} finally {$reader.Dispose()}
|
||||
} finally {$cmd.Dispose()}
|
||||
}
|
||||
$cn.Open()
|
||||
try {
|
||||
Exec 'SET NOCOUNT ON; SET XACT_ABORT ON; SET LOCK_TIMEOUT 1500; SET DEADLOCK_PRIORITY LOW;'
|
||||
$instance=[string](Scalar 'SELECT CONVERT(nvarchar(128),SERVERPROPERTY(''ServerName''))')
|
||||
$before=ReadDefinition
|
||||
$beforeHash=Hash $before.definition
|
||||
if($ExpectedSha256 -and $beforeHash -ne $ExpectedSha256){throw 'Procedure hash mismatch; recapture and review before retrying'}
|
||||
$null=Canonical $before.definition
|
||||
$fixedCount=([regex]::Matches($before.definition,[regex]::Escape($new))).Count
|
||||
$oldCount=([regex]::Matches($before.definition,[regex]::Escape($old))).Count
|
||||
if($oldCount -ne 1 -or $fixedCount -gt 1){throw 'Unexpected lock predicate; no changes made'}
|
||||
if($Mode -eq 'Check') {
|
||||
[ordered]@{mode=$Mode;server=$instance;database=$Database;sha256=$beforeHash;state=$(if($fixedCount -eq 1){'ALREADY_FIXED'}else{'NEEDS_FIX'});ansi=$before.ansi;quoted=$before.quoted}|ConvertTo-Json -Compress
|
||||
return
|
||||
}
|
||||
if($Mode -eq 'Apply' -and $fixedCount -eq 1) {
|
||||
[ordered]@{mode=$Mode;status='ALREADY_FIXED';sha256=$beforeHash;database_written=$false}|ConvertTo-Json -Compress
|
||||
return
|
||||
}
|
||||
if($Mode -eq 'Apply') {
|
||||
$target=(Canonical $before.definition).Replace($old,$new)
|
||||
$backup=[ordered]@{
|
||||
format='FT546WMS_SHADOW_LANE_LOCK_20260914';server=$instance;database=$Database
|
||||
utc=[DateTime]::UtcNow.ToString('o');definition=$before.definition;original_sha256=$beforeHash
|
||||
fixed_canonical_sha256=(Hash $target);ansi=$before.ansi;quoted=$before.quoted
|
||||
}
|
||||
# Backup is durable before beginning DDL; no existing file can be overwritten.
|
||||
$stream=[IO.File]::Open($BackupFile,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
|
||||
try {
|
||||
$bytes=[Text.Encoding]::UTF8.GetBytes(($backup|ConvertTo-Json -Depth 4))
|
||||
$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)
|
||||
} finally {$stream.Dispose()}
|
||||
} else {
|
||||
$backup=[IO.File]::ReadAllText($BackupFile)|ConvertFrom-Json
|
||||
if($backup.format -ne 'FT546WMS_SHADOW_LANE_LOCK_20260914' -or $backup.server -cne $instance -or $backup.database -cne $Database) {
|
||||
throw 'Backup identity mismatch'
|
||||
}
|
||||
if((Hash $backup.definition) -ne $backup.original_sha256){throw 'Backup integrity mismatch'}
|
||||
if($fixedCount -ne 1 -or (Hash (Canonical $before.definition)) -ne $backup.fixed_canonical_sha256) {
|
||||
throw 'Rollback refuses drift from the backed-up repair'
|
||||
}
|
||||
$target=Canonical $backup.definition
|
||||
}
|
||||
# Only the alg procedure is changed. No rebuild of the currently published generation,
|
||||
# no dbo DML, no application restart, no full migration and no active session is killed.
|
||||
$transaction=$cn.BeginTransaction()
|
||||
$lockResult=Scalar "DECLARE @r int; EXEC @r=sys.sp_getapplock @Resource=N'FT546WMS_ShadowLaneLockRepair',@LockMode='Exclusive',@LockOwner='Transaction',@LockTimeout=0; SELECT @r;"
|
||||
if($lockResult -lt 0){throw 'Another lane-lock repair is running'}
|
||||
$recheck=ReadDefinition
|
||||
if((Hash $recheck.definition) -ne $beforeHash -or $recheck.ansi -ne $before.ansi -or $recheck.quoted -ne $before.quoted) {
|
||||
throw 'Procedure changed during preparation; no changes made'
|
||||
}
|
||||
# Refuse unreviewed database DDL hooks that could make ALTER write business data.
|
||||
if((Scalar 'SELECT COUNT(*) FROM sys.triggers WHERE parent_class=0 AND is_disabled=0') -gt 0) {
|
||||
throw 'Enabled database DDL triggers require review before this repair'
|
||||
}
|
||||
$ansi=if($backup.ansi){'ON'}else{'OFF'}
|
||||
$quoted=if($backup.quoted){'ON'}else{'OFF'}
|
||||
Exec "SET ANSI_NULLS $ansi; SET QUOTED_IDENTIFIER $quoted;"
|
||||
$watch=[Diagnostics.Stopwatch]::StartNew()
|
||||
Exec $target
|
||||
$after=ReadDefinition
|
||||
if((Canonical $after.definition) -cne $target -or $after.ansi -ne $backup.ansi -or $after.quoted -ne $backup.quoted) {
|
||||
throw 'Definition verification failed; rolling back'
|
||||
}
|
||||
$transaction.Commit();$transaction.Dispose();$transaction=$null
|
||||
$watch.Stop()
|
||||
[ordered]@{
|
||||
mode=$Mode;status='OK';server=$instance;database=$Database;object='alg.usp_rebuild_lane_state'
|
||||
before_sha256=$beforeHash;after_sha256=(Hash $after.definition);ddl_ms=$watch.ElapsedMilliseconds
|
||||
backup=$BackupFile;refresh_invoked=$false;utc=[DateTime]::UtcNow.ToString('o')
|
||||
}|ConvertTo-Json -Compress
|
||||
} finally {
|
||||
if($transaction){try{$transaction.Rollback()}finally{$transaction.Dispose()}}
|
||||
$cn.Dispose()
|
||||
}
|
||||
104
scripts/shadow-lane-lock/test-shadow-lane-lock.ps1
Normal file
104
scripts/shadow-lane-lock/test-shadow-lane-lock.ps1
Normal file
@@ -0,0 +1,104 @@
|
||||
param(
|
||||
[string]$Server = 'lpc:.',
|
||||
[string]$SqlDirectory = "$PSScriptRoot/../../fortucky-algorithm/src/main/resources/db/sqlserver",
|
||||
[switch]$TestRepair
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
|
||||
# This runner accepts no database name: fixtures can only enter its own tiny scratch DB.
|
||||
$testDb = 'FT546WMS_LaneLock_Test_' + [Guid]::NewGuid().ToString('N')
|
||||
$cs = [Data.SqlClient.SqlConnectionStringBuilder]::new()
|
||||
$cs['Data Source']=$Server; $cs['Initial Catalog']='master'; $cs['Integrated Security']=$true
|
||||
$cs['Connect Timeout']=8; $cs['Application Name']='FT546ShadowLaneLockTests'
|
||||
$cn = [Data.SqlClient.SqlConnection]::new($cs.ConnectionString)
|
||||
function Exec([string]$sql) {
|
||||
$cmd=$cn.CreateCommand(); $cmd.CommandTimeout=15; $cmd.CommandText=$sql
|
||||
try { [void]$cmd.ExecuteNonQuery() } finally { $cmd.Dispose() }
|
||||
}
|
||||
function Scalar([string]$sql) {
|
||||
$cmd=$cn.CreateCommand(); $cmd.CommandTimeout=15; $cmd.CommandText=$sql
|
||||
try { $cmd.ExecuteScalar() } finally { $cmd.Dispose() }
|
||||
}
|
||||
function Assert([bool]$condition,[string]$message) {
|
||||
if(-not $condition){throw "FAIL: $message"}
|
||||
Write-Output "PASS: $message"
|
||||
}
|
||||
function Hash([string]$value) {
|
||||
$sha=[Security.Cryptography.SHA256]::Create()
|
||||
try { ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($value)))).Replace('-','').ToLowerInvariant() }
|
||||
finally { $sha.Dispose() }
|
||||
}
|
||||
function Definition { [string](Scalar "SELECT OBJECT_DEFINITION(OBJECT_ID('alg.usp_rebuild_lane_state'))") }
|
||||
function Canonical([string]$value) { [regex]::Replace($value,'(?i)\A\s*(CREATE|ALTER)\s+PROCEDURE','ALTER PROCEDURE') }
|
||||
$created=$false
|
||||
$cn.Open()
|
||||
try {
|
||||
$dataPath=([string](Scalar "SELECT CONVERT(nvarchar(4000),SERVERPROPERTY('InstanceDefaultDataPath'))")).Replace("'","''")
|
||||
$logPath=([string](Scalar "SELECT CONVERT(nvarchar(4000),SERVERPROPERTY('InstanceDefaultLogPath'))")).Replace("'","''")
|
||||
Exec "CREATE DATABASE [$testDb] ON PRIMARY (NAME=N'$testDb',FILENAME=N'${dataPath}$testDb.mdf',SIZE=8MB,MAXSIZE=64MB,FILEGROWTH=8MB) LOG ON (NAME=N'${testDb}_log',FILENAME=N'${logPath}${testDb}_log.ldf',SIZE=8MB,MAXSIZE=64MB,FILEGROWTH=8MB)"
|
||||
$created=$true; $cn.ChangeDatabase($testDb)
|
||||
Exec 'SET NOCOUNT ON; SET LOCK_TIMEOUT 1500; SET DEADLOCK_PRIORITY LOW;'
|
||||
Exec 'CREATE SCHEMA alg'
|
||||
foreach($batch in ([IO.File]::ReadAllText("$PSScriptRoot/fixture.sql") -split '(?im)^\s*GO\s*\r?$')) {
|
||||
if($batch.Trim()){ Exec $batch }
|
||||
}
|
||||
foreach($source in @('install-shadow.sql','upgrade-shadow-v2-to-v3.sql')) {
|
||||
$text=[IO.File]::ReadAllText((Join-Path $SqlDirectory $source))
|
||||
$bodies=@($text -split '(?im)^\s*GO\s*\r?$' | Where-Object {$_ -match '(?im)^\s*(CREATE|ALTER) PROCEDURE alg\.usp_rebuild_lane_state\b'})
|
||||
Assert ($bodies.Count -eq 1) "$source has exactly one real rebuild body"
|
||||
$body=$bodies[0]
|
||||
$start=[regex]::Match($body,'(?im)^\s*(CREATE|ALTER) PROCEDURE alg\.usp_rebuild_lane_state\b').Index
|
||||
$body=$body.Substring($start).Trim()
|
||||
Exec "IF OBJECT_ID('alg.usp_rebuild_lane_state') IS NOT NULL DROP PROCEDURE alg.usp_rebuild_lane_state"
|
||||
Exec ([regex]::Replace($body,'(?i)\A(CREATE|ALTER) PROCEDURE','CREATE PROCEDURE'))
|
||||
Exec 'DELETE alg.lane_state'
|
||||
Exec "EXEC alg.usp_rebuild_lane_state @warehouse_id='01'"
|
||||
Exec ([IO.File]::ReadAllText("$PSScriptRoot/assertions.sql"))
|
||||
Write-Output "PASS: $source actual T-SQL aggregation, exclusions, repeat and scoping"
|
||||
if($TestRepair) {
|
||||
$old="NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL"
|
||||
$new="(NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL OR LTRIM(RTRIM(cargo.locked)) IN ('0', 'N'))"
|
||||
$original=(Canonical (Definition)).Replace($new,$old)
|
||||
Exec $original
|
||||
$before=Definition
|
||||
$backup=Join-Path $PSScriptRoot "$testDb-$source.backup.json"
|
||||
$repair=Join-Path $PSScriptRoot 'repair-shadow-lane-lock.ps1'
|
||||
try {
|
||||
$null=& $repair -Server $Server -Database $testDb -Mode Check -ExpectedSha256 (Hash $before)
|
||||
Assert ((Definition) -ceq $before) 'check mode leaves procedure unchanged'
|
||||
$null=& $repair -Server $Server -Database $testDb -Mode Apply -ExpectedSha256 (Hash $before) -BackupFile $backup
|
||||
$fixed=Definition
|
||||
Assert ((Canonical $fixed) -ceq (Canonical $before).Replace($old,$new)) 'repair changes only the lock predicate'
|
||||
Exec "EXEC alg.usp_rebuild_lane_state @warehouse_id='01'"
|
||||
Exec ([IO.File]::ReadAllText("$PSScriptRoot/assertions.sql"))
|
||||
$null=& $repair -Server $Server -Database $testDb -Mode Apply -ExpectedSha256 (Hash $fixed) -BackupFile $backup
|
||||
Assert ((Definition) -ceq $fixed) 'repeat apply is a no-op'
|
||||
$refused=$false
|
||||
try { $null=& $repair -Server $Server -Database $testDb -Mode Apply -ExpectedSha256 ('0'*64) -BackupFile $backup }
|
||||
catch { $refused=$_.Exception.Message -like '*hash mismatch*' }
|
||||
Assert $refused 'unexpected definition is refused'
|
||||
# Invalid ALTER must roll back atomically, including connection transaction cleanup.
|
||||
$tx=$cn.BeginTransaction()
|
||||
$cmd=$cn.CreateCommand(); $cmd.Transaction=$tx; $cmd.CommandText='ALTER PROCEDURE alg.usp_rebuild_lane_state AS INVALID SQL HERE'
|
||||
$failed=$false
|
||||
try { [void]$cmd.ExecuteNonQuery() } catch { $failed=$true } finally { $tx.Rollback();$cmd.Dispose();$tx.Dispose() }
|
||||
Assert ($failed -and (Definition) -ceq $fixed) 'failed ALTER preserves the published procedure'
|
||||
$null=& $repair -Server $Server -Database $testDb -Mode Rollback -ExpectedSha256 (Hash $fixed) -BackupFile $backup
|
||||
Assert ((Canonical (Definition)) -ceq (Canonical $before)) 'rollback restores the exact original body'
|
||||
Exec "EXEC alg.usp_rebuild_lane_state @warehouse_id='01'"
|
||||
Assert ((Scalar "SELECT available_count FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='UNLOCK_N'") -eq 0) 'rollback reproduces the original N exclusion'
|
||||
} finally {
|
||||
if(Test-Path -LiteralPath $backup){ Remove-Item -LiteralPath $backup }
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if($created) {
|
||||
$cn.ChangeDatabase('master')
|
||||
[Data.SqlClient.SqlConnection]::ClearAllPools()
|
||||
if($testDb -notmatch '^FT546WMS_LaneLock_Test_[a-f0-9]{32}$'){throw 'Invalid scratch database name'}
|
||||
Exec "DROP DATABASE [$testDb]"
|
||||
Write-Output "Scratch database removed: $testDb"
|
||||
}
|
||||
$cn.Dispose()
|
||||
}
|
||||
@@ -10,31 +10,37 @@ set "DATABASE=%~2"
|
||||
set "MODE=%~3"
|
||||
set "SQL_FILE="
|
||||
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"=="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 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 "%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
|
||||
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 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%"
|
||||
if errorlevel 1 exit /b 1
|
||||
if /I "%MODE%"=="v33" goto :installed
|
||||
if /I "%MODE%"=="v34" goto :installed
|
||||
sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -i "%V33_FILE%"
|
||||
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
|
||||
echo INSTALL_OK mode=%MODE%
|
||||
exit /b 0
|
||||
|
||||
: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
|
||||
|
||||
:fail
|
||||
|
||||
@@ -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"
|
||||
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%"
|
||||
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%
|
||||
exit /b 0
|
||||
|
||||
|
||||
85
scripts/shadow-v34-schema-check.sql
Normal file
85
scripts/shadow-v34-schema-check.sql
Normal 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';
|
||||
Reference in New Issue
Block a user