diff --git a/docs/superpowers/plans/2026-08-07-javaTest-source-aligned-two-stage-task.md b/docs/superpowers/plans/2026-08-07-javaTest-source-aligned-two-stage-task.md index 3a71405..cf51af7 100644 --- a/docs/superpowers/plans/2026-08-07-javaTest-source-aligned-two-stage-task.md +++ b/docs/superpowers/plans/2026-08-07-javaTest-source-aligned-two-stage-task.md @@ -257,7 +257,7 @@ public final class HistoricalBusinessTaskAssembler { "dataset, compiled map and channel fronts are required"); } List ordered = stableOrder(dataset.getRecords()); - Map> groups = groupBusinessRows(ordered); + Map> groups = groupBusinessRows(ordered); return buildAssembly(dataset.getRecords(), groups, compiled, fronts); } @@ -267,7 +267,7 @@ public final class HistoricalBusinessTaskAssembler { } ``` -Use `task_no + cargo_pick_site + cargo_place_site` as the business grouping key, sort groups by earliest `create_date/task_no`, and create IDs as `BUSINESS@#`. Keep every raw GUID/snapshot row as `HistoricalTaskSnapshotEvidence`. Resolve physical endpoints from `cargo_pick_site/cargo_place_site`, not historical queue `start_site/end_site`. +Use `task_no` as the parent grouping key, sort groups by earliest `create_date/task_no`, and create IDs as `BUSINESS@#`. Keep every raw GUID/snapshot row as `HistoricalTaskSnapshotEvidence`. Resolve the parent business type, priority, and physical endpoints only from rows whose `default02` is `1/2/7`; `default02=4/8` rows are pickup/floor-transfer evidence and their intermediate endpoints must not overwrite the parent. This distinction is required by fixture task `50089`, whose transfer target differs from its final outbound target. - [ ] **Step 4: Preserve raw compatibility explicitly** diff --git a/docs/superpowers/specs/2026-08-07-javaTest-source-aligned-two-stage-task-design.md b/docs/superpowers/specs/2026-08-07-javaTest-source-aligned-two-stage-task-design.md index b7ffc77..bf124bc 100644 --- a/docs/superpowers/specs/2026-08-07-javaTest-source-aligned-two-stage-task-design.md +++ b/docs/superpowers/specs/2026-08-07-javaTest-source-aligned-two-stage-task-design.md @@ -132,9 +132,9 @@ frontSiteId 1. 原始行按 `create_date/snapshot_row/guid` 稳定排序; 2. 相同 GUID 的重复扫描只保留状态演进证据,不重复创建任务; 3. `task_type=3` 归类为车辆运行证据,不创建 WMS 业务父任务; -4. `task_type=1/2` 按 `task_no`、`cargo_pick_site`、`cargo_place_site` 合并; -5. 从组内 `default02=1/2/7` 解析唯一业务类型; -6. 组内端点、业务类型或优先级出现互相冲突时,关闭为 `CONFLICTING_HISTORY_SNAPSHOTS`,不得任意选一条; +4. `task_type=1/2` 先按 `task_no` 归并成一个业务父任务候选,组内所有 GUID 和重复扫描都保留为阶段证据; +5. 只从组内 `default02=1/2/7` 的业务快照解析唯一业务类型、`cargo_pick_site`、`cargo_place_site` 和优先级; +6. `default02=4/8` 的取货或换层快照可以携带中转端点,但不得覆盖父任务字段;只有业务快照之间端点、业务类型或优先级互相冲突时,才关闭为 `CONFLICTING_HISTORY_SNAPSHOTS`; 7. 最早 `create_date` 控制业务到达,最晚历史 `finish_date` 只用于耗时比较; 8. 历史 `agv_id/path_info/task_type/default06/default08` 作为阶段对照证据,不强制本次选车和路径。 diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalBusinessAssembly.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalBusinessAssembly.java new file mode 100644 index 0000000..e41727d --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalBusinessAssembly.java @@ -0,0 +1,26 @@ +package com.ft546.wcs.shadow.history.simulation; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** 业务快照归并结果:可调度父任务、明确失败输入和原始数据审计。 */ +public final class HistoricalBusinessAssembly { + private final List tasks; + private final List failures; + private final HistoricalInputAudit audit; + + HistoricalBusinessAssembly(List tasks, + List failures, + HistoricalInputAudit audit) { + this.tasks = Collections.unmodifiableList( + new ArrayList(tasks)); + this.failures = Collections.unmodifiableList( + new ArrayList(failures)); + this.audit = audit; + } + + public List getTasks() { return tasks; } + public List getFailures() { return failures; } + public HistoricalInputAudit getAudit() { return audit; } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalBusinessTaskAssembler.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalBusinessTaskAssembler.java new file mode 100644 index 0000000..aa97c68 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalBusinessTaskAssembler.java @@ -0,0 +1,244 @@ +package com.ft546.wcs.shadow.history.simulation; + +import com.ft546.wcs.demo.model.DispatchTask; +import com.ft546.wcs.shadow.history.HistoricalPath; +import com.ft546.wcs.shadow.history.HistoricalQueueDataset; +import com.ft546.wcs.shadow.history.HistoricalQueueNormalizer; +import com.ft546.wcs.shadow.history.HistoricalQueueRecord; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * 将重复的生产队列扫描状态归并成一次可调度的 WMS 业务父任务。 + * + *

{@code task_type} 只决定快照属于空车、载货还是车辆运行阶段;入库、出库、移库必须从 + * 同一业务号内唯一的 {@code default02=1/2/7} 解析。任何矛盾都形成可审计失败,不能任意取值。 + */ +public final class HistoricalBusinessTaskAssembler { + public HistoricalBusinessAssembly assemble( + HistoricalQueueDataset dataset, + HistoricalGridMapCompiler.CompiledHistoricalMap compiled, + ChannelFrontTopology fronts) { + if (dataset == null || compiled == null || fronts == null) { + throw new IllegalArgumentException( + "dataset, compiled map and channel fronts are required"); + } + + List ordered = normalize(dataset); + Map> groups = groupBusinessRows(ordered); + List tasks = new ArrayList(); + List failures = + new ArrayList(); + int sequence = 0; + for (Map.Entry> entry : groups.entrySet()) { + int stableSequence = sequence++; + assembleGroup(entry.getKey(), entry.getValue(), stableSequence, + compiled, fronts, tasks, failures); + } + return new HistoricalBusinessAssembly(tasks, failures, + HistoricalInputAudit.from(dataset.getRecords(), groups.size())); + } + + /** 先规范化时间与参考路径,再按生产到达时间和稳定历史标识排序。 */ + private List normalize(HistoricalQueueDataset dataset) { + HistoricalQueueNormalizer normalizer = new HistoricalQueueNormalizer(); + List rows = new ArrayList(); + for (HistoricalQueueRecord record : dataset.getRecords()) { + rows.add(new NormalizedRow(normalizer.normalize(record, dataset.getMapSites()))); + } + Collections.sort(rows, Comparator + .comparing(NormalizedRow::createdAt, + Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(row -> text(row.record().getTaskNo())) + .thenComparing(row -> text(row.record().getGuid())) + .thenComparing(row -> text(row.record().getSnapshotRow()))); + return rows; + } + + /** 只有空车/载货阶段属于业务父任务;task_type=3 保留在输入审计中但不重复回调 WMS。 */ + private Map> groupBusinessRows(List ordered) { + Map> groups = + new LinkedHashMap>(); + for (NormalizedRow row : ordered) { + if (!"1".equals(row.record().getTaskType()) + && !"2".equals(row.record().getTaskType())) continue; + String taskNo = text(row.record().getTaskNo()); + if (!groups.containsKey(taskNo)) { + groups.put(taskNo, new ArrayList()); + } + groups.get(taskNo).add(row); + } + return groups; + } + + /** 校验一个业务号内的类型、端点和优先级,并创建唯一父任务或唯一失败结果。 */ + private void assembleGroup(String taskNo, List rows, int sequence, + HistoricalGridMapCompiler.CompiledHistoricalMap compiled, + ChannelFrontTopology fronts, + List tasks, + List failures) { + HistoricalQueueRecord fallback = rows.get(0).record(); + String id = "BUSINESS@" + taskNo + "#" + sequence; + if (taskNo.isEmpty()) { + failures.add(new HistoricalSimulationFailure( + id, fallback, "CONFLICTING_HISTORY_SNAPSHOTS:TASK_NO")); + return; + } + + Set types = new LinkedHashSet(); + Set sources = new LinkedHashSet(); + Set targets = new LinkedHashSet(); + Set priorities = new LinkedHashSet(); + for (NormalizedRow row : rows) { + DispatchTask.TaskType type = businessType(row.record().getDefault02()); + if (type == null) { + // default02=4/8 是取货或换层阶段,端点可能是中转站,只保留为阶段证据。 + continue; + } + // 父任务的业务类型、货物端点和优先级只能由 1/2/7 业务记录定义。 + types.add(type); + addNonBlank(sources, row.record().getCargoPickSite()); + addNonBlank(targets, row.record().getCargoPlaceSite()); + if (row.record().getPriority() != null) priorities.add(row.record().getPriority()); + } + if (types.size() != 1) { + failures.add(new HistoricalSimulationFailure( + id, fallback, "CONFLICTING_HISTORY_SNAPSHOTS:BUSINESS_TYPE")); + return; + } + if (sources.size() != 1 || targets.size() != 1) { + failures.add(new HistoricalSimulationFailure( + id, fallback, "CONFLICTING_HISTORY_SNAPSHOTS:BUSINESS_ENDPOINT")); + return; + } + if (priorities.size() > 1) { + failures.add(new HistoricalSimulationFailure( + id, fallback, "CONFLICTING_HISTORY_SNAPSHOTS:PRIORITY")); + return; + } + + String source = sources.iterator().next(); + String target = targets.iterator().next(); + String invalidSite = missingSite(source, target, compiled); + if (invalidSite != null) { + failures.add(new HistoricalSimulationFailure( + id, fallback, "UNKNOWN_MAP_SITE:" + invalidSite)); + return; + } + String sourceFront = front(fronts, source); + String targetFront = front(fronts, target); + String invalidFront = missingFront(sourceFront, targetFront, compiled); + if (invalidFront != null) { + failures.add(new HistoricalSimulationFailure( + id, fallback, "UNKNOWN_MAP_SITE:CHANNEL_FRONT:" + invalidFront)); + return; + } + + NormalizedRow representative = representative(rows); + LocalDateTime createdAt = earliestCreated(rows); + if (createdAt == null) { + failures.add(new HistoricalSimulationFailure(id, representative.record(), + "INVALID_CREATE_DATE")); + return; + } + List evidence = + new ArrayList(); + for (NormalizedRow row : rows) { + evidence.add(HistoricalTaskSnapshotEvidence.from(row.record())); + } + int priority = priorities.isEmpty() ? Integer.MAX_VALUE + : priorities.iterator().next(); + tasks.add(new HistoricalSimulationTask(id, representative.record(), + types.iterator().next(), priority, createdAt, latestFinished(rows), + source, target, representative.path().getNodes(), sequence, + sourceFront, targetFront, evidence)); + } + + /** 优先使用带最终业务类型的载货阶段作为历史对照记录。 */ + private NormalizedRow representative(List rows) { + for (NormalizedRow row : rows) { + if (businessType(row.record().getDefault02()) != null + && "2".equals(row.record().getTaskType())) return row; + } + for (NormalizedRow row : rows) { + if (businessType(row.record().getDefault02()) != null) return row; + } + return rows.get(0); + } + + private DispatchTask.TaskType businessType(String value) { + if ("1".equals(value)) return DispatchTask.TaskType.INBOUND; + if ("2".equals(value)) return DispatchTask.TaskType.OUTBOUND; + if ("7".equals(value)) return DispatchTask.TaskType.RELOCATION; + return null; + } + + private LocalDateTime earliestCreated(List rows) { + LocalDateTime value = null; + for (NormalizedRow row : rows) { + if (row.createdAt() != null && (value == null || row.createdAt().isBefore(value))) { + value = row.createdAt(); + } + } + return value; + } + + private LocalDateTime latestFinished(List rows) { + LocalDateTime value = null; + for (NormalizedRow row : rows) { + LocalDateTime current = row.path().getFinishedAt(); + if (current != null && (value == null || current.isAfter(value))) value = current; + } + return value; + } + + private String missingSite(String source, String target, + HistoricalGridMapCompiler.CompiledHistoricalMap compiled) { + if (!compiled.getPassableSites().containsKey(source)) return source; + if (!compiled.getPassableSites().containsKey(target)) return target; + return null; + } + + private String missingFront(String sourceFront, String targetFront, + HistoricalGridMapCompiler.CompiledHistoricalMap compiled) { + if (sourceFront != null && !compiled.getPassableSites().containsKey(sourceFront)) { + return sourceFront; + } + if (targetFront != null && !compiled.getPassableSites().containsKey(targetFront)) { + return targetFront; + } + return null; + } + + private String front(ChannelFrontTopology fronts, String site) { + Optional value = fronts.frontFor(site); + return value.isPresent() ? value.get() : null; + } + + private void addNonBlank(Set values, String value) { + String normalized = text(value); + if (!normalized.isEmpty()) values.add(normalized); + } + + private static String text(String value) { + return value == null ? "" : value.trim(); + } + + private static final class NormalizedRow { + private final HistoricalPath path; + + private NormalizedRow(HistoricalPath path) { this.path = path; } + private HistoricalPath path() { return path; } + private HistoricalQueueRecord record() { return path.getRecord(); } + private LocalDateTime createdAt() { return path.getCreatedAt(); } + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalInputAudit.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalInputAudit.java new file mode 100644 index 0000000..ce9f2b7 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalInputAudit.java @@ -0,0 +1,70 @@ +package com.ft546.wcs.shadow.history.simulation; + +import com.ft546.wcs.shadow.history.HistoricalQueueRecord; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** 保存从原始扫描行到唯一队列和业务父任务的双口径数据质量统计。 */ +public final class HistoricalInputAudit { + private final int rawSnapshotCount; + private final int uniqueQueueCount; + private final int duplicateSnapshotCount; + private final int businessTaskCount; + private final int operationalQueueCount; + + public HistoricalInputAudit(int rawSnapshotCount, int uniqueQueueCount, + int duplicateSnapshotCount, int businessTaskCount, + int operationalQueueCount) { + if (rawSnapshotCount < 0 || uniqueQueueCount < 0 || duplicateSnapshotCount < 0 + || businessTaskCount < 0 || operationalQueueCount < 0 + || uniqueQueueCount + duplicateSnapshotCount != rawSnapshotCount) { + throw new IllegalArgumentException("consistent non-negative audit counts are required"); + } + this.rawSnapshotCount = rawSnapshotCount; + this.uniqueQueueCount = uniqueQueueCount; + this.duplicateSnapshotCount = duplicateSnapshotCount; + this.businessTaskCount = businessTaskCount; + this.operationalQueueCount = operationalQueueCount; + } + + /** 按 GUID 去重,并把 task_type=3 的唯一队列识别为车辆运行作业。 */ + static HistoricalInputAudit from(List rows, int businessTaskCount) { + Set unique = new LinkedHashSet(); + Set operational = new LinkedHashSet(); + for (HistoricalQueueRecord row : rows) { + String guid = text(row.getGuid()); + if (!guid.isEmpty()) unique.add(guid); + if ("3".equals(row.getTaskType()) && !guid.isEmpty()) operational.add(guid); + } + return new HistoricalInputAudit(rows.size(), unique.size(), rows.size() - unique.size(), + businessTaskCount, operational.size()); + } + + /** 按业务号统计空车/载货阶段,用于原始回放仍展示同一份业务口径。 */ + static HistoricalInputAudit from(List rows) { + Set businessTaskNos = new LinkedHashSet(); + for (HistoricalQueueRecord row : rows) { + if (("1".equals(row.getTaskType()) || "2".equals(row.getTaskType())) + && !text(row.getTaskNo()).isEmpty()) { + businessTaskNos.add(text(row.getTaskNo())); + } + } + return from(rows, businessTaskNos.size()); + } + + static HistoricalInputAudit syntheticRaw(int taskCount) { + return new HistoricalInputAudit(taskCount, taskCount, 0, 0, 0); + } + + public int getRawSnapshotCount() { return rawSnapshotCount; } + public int getUniqueQueueCount() { return uniqueQueueCount; } + public int getDuplicateSnapshotCount() { return duplicateSnapshotCount; } + public int getBusinessTaskCount() { return businessTaskCount; } + public int getOperationalQueueCount() { return operationalQueueCount; } + + private static String text(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInput.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInput.java index fa716ef..092c6bc 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInput.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInput.java @@ -15,6 +15,9 @@ public final class HistoricalSimulationInput { private final EffectiveTrafficMap trafficMap; private final List initialVehicles; private final FloorTransferTopology floorTransferTopology; + private final SimulationExecutionMode executionMode; + private final HistoricalInputAudit inputAudit; + private final ChannelFrontTopology channelFrontTopology; /** * 创建 {@code HistoricalSimulationInput} 实例并初始化其内部状态。 @@ -51,6 +54,23 @@ public final class HistoricalSimulationInput { GridMap gridMap, EffectiveTrafficMap trafficMap, List initialVehicles, FloorTransferTopology floorTransferTopology) { + this(tasks, failedInputs, gridMap, trafficMap, initialVehicles, + floorTransferTopology, SimulationExecutionMode.RAW_SNAPSHOT, + HistoricalInputAudit.syntheticRaw(tasks.size() + failedInputs.size()), + ChannelFrontTopology.from( + Collections. + emptyList())); + } + + /** 创建带原始审计口径和通道前端目录的明确执行模式输入。 */ + HistoricalSimulationInput(List tasks, + List failedInputs, + GridMap gridMap, EffectiveTrafficMap trafficMap, + List initialVehicles, + FloorTransferTopology floorTransferTopology, + SimulationExecutionMode executionMode, + HistoricalInputAudit inputAudit, + ChannelFrontTopology channelFrontTopology) { this.tasks = immutable(tasks); this.failedInputs = immutable(failedInputs); this.gridMap = gridMap; @@ -60,6 +80,13 @@ public final class HistoricalSimulationInput { throw new IllegalArgumentException("floor transfer topology is required"); } this.floorTransferTopology = floorTransferTopology; + if (executionMode == null || inputAudit == null || channelFrontTopology == null) { + throw new IllegalArgumentException( + "execution mode, input audit and channel front topology are required"); + } + this.executionMode = executionMode; + this.inputAudit = inputAudit; + this.channelFrontTopology = channelFrontTopology; } /** @@ -101,6 +128,15 @@ public final class HistoricalSimulationInput { /** 返回项目换层端口目录;该目录不向普通路径规划器暴露跨层边。 */ public FloorTransferTopology getFloorTransferTopology() { return floorTransferTopology; } + /** 返回本次输入采用的原始快照或源码一致业务回放模式。 */ + public SimulationExecutionMode getExecutionMode() { return executionMode; } + + /** 返回 240 原始行、唯一 GUID、重复行和业务父任务的双口径审计。 */ + public HistoricalInputAudit getInputAudit() { return inputAudit; } + + /** 返回生产货位分组快照构建的通道前端目录。 */ + public ChannelFrontTopology getChannelFrontTopology() { return channelFrontTopology; } + /** * 执行“all simulation ids”所表示的处理流程。 * diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInputFactory.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInputFactory.java index f032cd9..b7fffdb 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInputFactory.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInputFactory.java @@ -16,7 +16,25 @@ import java.util.Map; public final class HistoricalSimulationInputFactory { /** - * 将历史队列和完整地图转换为中心调度可直接执行的仿真输入。 + * 默认按生产源码语义把重复队列阶段归并成业务父任务,并附加权威通道前端目录。 + */ + public HistoricalSimulationInput create(HistoricalQueueDataset dataset) { + if (dataset == null) throw new IllegalArgumentException("dataset is required"); + HistoricalGridMapCompiler.CompiledHistoricalMap compiled = + new HistoricalGridMapCompiler().compile(dataset.getFullMapCells()); + ChannelFrontTopology fronts = ChannelFrontTopology.from( + dataset.getChannelFrontRecords()); + HistoricalBusinessAssembly assembly = new HistoricalBusinessTaskAssembler() + .assemble(dataset, compiled, fronts); + return new HistoricalSimulationInput(assembly.getTasks(), assembly.getFailures(), + compiled.getGridMap(), compiled.getTrafficMap(), initialVehicles(compiled), + floorTransferTopology(compiled), + SimulationExecutionMode.SOURCE_ALIGNED_BUSINESS, + assembly.getAudit(), fronts); + } + + /** + * 将每条历史扫描行按旧口径转换为独立任务,只用于和改造前的 240 行结果对照。 * *

历史记录先规范化并按 {@code create_date/task_no/guid} 稳定排序;再由完整地图解析物理端点和连通性。 * 任务号、类型、起终点、优先级保留为业务输入,历史路径只保留作对照;非法任务类型形成 @@ -24,7 +42,7 @@ public final class HistoricalSimulationInputFactory { * @param dataset 执行该处理所需的 dataset 输入。 * @return 包含可调度任务、输入失败、完整交通图和初始车队的仿真输入。 */ - public HistoricalSimulationInput create(HistoricalQueueDataset dataset) { + public HistoricalSimulationInput createRawSnapshots(HistoricalQueueDataset dataset) { if (dataset == null) throw new IllegalArgumentException("dataset is required"); HistoricalQueueNormalizer normalizer = new HistoricalQueueNormalizer(); List paths = new ArrayList(); @@ -76,8 +94,12 @@ public final class HistoricalSimulationInputFactory { } List vehicles = initialVehicles(compiled); + ChannelFrontTopology fronts = ChannelFrontTopology.from( + dataset.getChannelFrontRecords()); return new HistoricalSimulationInput(tasks, failures, compiled.getGridMap(), - compiled.getTrafficMap(), vehicles, floorTransferTopology(compiled)); + compiled.getTrafficMap(), vehicles, floorTransferTopology(compiled), + SimulationExecutionMode.RAW_SNAPSHOT, + HistoricalInputAudit.from(dataset.getRecords()), fronts); } /** diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationTask.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationTask.java index 33f676c..70b487d 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationTask.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationTask.java @@ -19,6 +19,9 @@ public final class HistoricalSimulationTask { private final String physicalTarget; private final List historicalNodes; private final int arrivalSequence; + private final String sourceFrontSite; + private final String targetFrontSite; + private final List snapshotEvidence; /** * 创建 {@code HistoricalSimulationTask} 实例并初始化其内部状态。 @@ -40,6 +43,22 @@ public final class HistoricalSimulationTask { LocalDateTime createdAt, LocalDateTime historicalFinishedAt, String physicalSource, String physicalTarget, List historicalNodes, int arrivalSequence) { + this(simulationTaskId, record, taskType, priority, createdAt, + historicalFinishedAt, physicalSource, physicalTarget, + historicalNodes, arrivalSequence, null, null, + Collections.emptyList()); + } + + /** + * 创建源码一致的业务父任务,同时保存源/目标通道前端和全部原始阶段快照证据。 + */ + HistoricalSimulationTask(String simulationTaskId, HistoricalQueueRecord record, + DispatchTask.TaskType taskType, int priority, + LocalDateTime createdAt, LocalDateTime historicalFinishedAt, + String physicalSource, String physicalTarget, + List historicalNodes, int arrivalSequence, + String sourceFrontSite, String targetFrontSite, + List snapshotEvidence) { this.simulationTaskId = simulationTaskId; this.record = record; this.taskType = taskType; @@ -51,6 +70,10 @@ public final class HistoricalSimulationTask { this.historicalNodes = Collections.unmodifiableList( new ArrayList(historicalNodes)); this.arrivalSequence = arrivalSequence; + this.sourceFrontSite = sourceFrontSite; + this.targetFrontSite = targetFrontSite; + this.snapshotEvidence = Collections.unmodifiableList( + new ArrayList(snapshotEvidence)); } /** @@ -123,4 +146,23 @@ public final class HistoricalSimulationTask { * @return 本次处理产生的结果;调用方据此继续后续流程。 */ public int getArrivalSequence() { return arrivalSequence; } + + /** 返回源货位所属通道前端;外部输送点没有货位分组时返回 {@code null}。 */ + public String getSourceFrontSite() { return sourceFrontSite; } + + /** 返回目标货位所属通道前端;外部输送点没有货位分组时返回 {@code null}。 */ + public String getTargetFrontSite() { return targetFrontSite; } + + public boolean requiresSourceFrontMovement() { + return sourceFrontSite != null && !sourceFrontSite.equals(physicalSource); + } + + public boolean requiresTargetFrontMovement() { + return targetFrontSite != null && !targetFrontSite.equals(physicalTarget); + } + + /** 返回归并到该父任务的全部历史空车、载货和重复扫描证据。 */ + public List getSnapshotEvidence() { + return snapshotEvidence; + } } diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalTaskSnapshotEvidence.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalTaskSnapshotEvidence.java new file mode 100644 index 0000000..aacac1e --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalTaskSnapshotEvidence.java @@ -0,0 +1,34 @@ +package com.ft546.wcs.shadow.history.simulation; + +import com.ft546.wcs.shadow.history.HistoricalQueueRecord; + +/** 保存父任务关联的每一条原始队列快照,供阶段和车辆路径对照。 */ +public final class HistoricalTaskSnapshotEvidence { + private final String snapshotRow; + private final String guid; + private final String queueTaskType; + private final String default02; + private final String historicalVehicleId; + private final String historicalPath; + + private HistoricalTaskSnapshotEvidence(HistoricalQueueRecord record) { + this.snapshotRow = record.getSnapshotRow(); + this.guid = record.getGuid(); + this.queueTaskType = record.getTaskType(); + this.default02 = record.getDefault02(); + this.historicalVehicleId = record.getAgvId(); + this.historicalPath = record.getPathInfo(); + } + + public static HistoricalTaskSnapshotEvidence from(HistoricalQueueRecord record) { + if (record == null) throw new IllegalArgumentException("history record is required"); + return new HistoricalTaskSnapshotEvidence(record); + } + + public String getSnapshotRow() { return snapshotRow; } + public String getGuid() { return guid; } + public String getQueueTaskType() { return queueTaskType; } + public String getDefault02() { return default02; } + public String getHistoricalVehicleId() { return historicalVehicleId; } + public String getHistoricalPath() { return historicalPath; } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/PointToPointSimulationInputFactory.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/PointToPointSimulationInputFactory.java index e2fe667..a3ffa6e 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/PointToPointSimulationInputFactory.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/PointToPointSimulationInputFactory.java @@ -46,13 +46,24 @@ public final class PointToPointSimulationInputFactory { sourceSite, targetSite, pathInfo, TIME.format(CREATED_AT), TIME.format(CREATED_AT.plusMinutes(1)), historicalVehicleId); + + // A/B 审计沿用生产货位分组:有分组的端点进入二段式通道作业,没有分组的端点仍是普通站点。 + ChannelFrontTopology fronts = base.getChannelFrontTopology(); + String sourceFront = fronts.frontFor(sourceSite).orElse(null); + String targetFront = fronts.frontFor(targetSite).orElse(null); HistoricalSimulationTask task = new HistoricalSimulationTask( TASK_ID, record, taskType, priority, CREATED_AT, - CREATED_AT.plusMinutes(1), sourceSite, targetSite, history, 0); + CREATED_AT.plusMinutes(1), sourceSite, targetSite, history, 0, + sourceFront, targetFront, + Collections.emptyList()); + + // 单任务审计仍按一条业务父任务报告,不能因复用旧构造器被标记成原始快照回放。 return new HistoricalSimulationInput( Collections.singletonList(task), Collections.emptyList(), base.getGridMap(), base.getTrafficMap(), base.getInitialVehicles(), - base.getFloorTransferTopology()); + base.getFloorTransferTopology(), + SimulationExecutionMode.SOURCE_ALIGNED_BUSINESS, + new HistoricalInputAudit(1, 1, 0, 1, 0), fronts); } private String taskTypeCode(DispatchTask.TaskType taskType) { diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationExecutionMode.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationExecutionMode.java new file mode 100644 index 0000000..5dd41f1 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationExecutionMode.java @@ -0,0 +1,7 @@ +package com.ft546.wcs.shadow.history.simulation; + +/** 区分旧的逐行快照对照回放和源码一致的业务父任务回放。 */ +public enum SimulationExecutionMode { + RAW_SNAPSHOT, + SOURCE_ALIGNED_BUSINESS +} diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalBusinessTaskAssemblerTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalBusinessTaskAssemblerTest.java new file mode 100644 index 0000000..2396f89 --- /dev/null +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalBusinessTaskAssemblerTest.java @@ -0,0 +1,116 @@ +package com.ft546.wcs.shadow.history.simulation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ft546.wcs.demo.model.DispatchTask; +import com.ft546.wcs.shadow.history.HistoricalQueueDataset; +import com.ft546.wcs.shadow.history.HistoricalQueueLoader; +import com.ft546.wcs.shadow.history.HistoricalQueueRecord; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HistoricalBusinessTaskAssemblerTest { + private static final ObjectMapper JSON = new ObjectMapper(); + private static HistoricalQueueDataset dataset; + + @BeforeAll + static void loadDataset() { + dataset = new HistoricalQueueLoader().load( + HistoricalBusinessTaskAssemblerTest.class.getClassLoader(), "shadow/history"); + } + + /** 防止把 task_type 执行阶段继续误当作入库、出库、移库业务类型。 */ + @Test + void derivesBusinessTypeFromDefault02AndKeepsAllSnapshotEvidence() { + HistoricalSimulationInput input = new HistoricalSimulationInputFactory().create(dataset); + + assertEquals(38, count(input, DispatchTask.TaskType.INBOUND)); + assertEquals(33, count(input, DispatchTask.TaskType.OUTBOUND)); + assertEquals(5, count(input, DispatchTask.TaskType.RELOCATION)); + + HistoricalSimulationTask task60014 = input.getTasks().stream() + .filter(task -> "60014".equals(task.getRecord().getTaskNo())) + .findFirst().get(); + assertEquals(DispatchTask.TaskType.INBOUND, task60014.getTaskType()); + assertEquals("050101", task60014.getPhysicalSource()); + assertEquals("030401", task60014.getPhysicalTarget()); + assertEquals("050401", task60014.getTargetFrontSite()); + assertTrue(task60014.getSnapshotEvidence().size() > 1); + assertFalse(task60014.getSimulationTaskId().contains(task60014.getRecord().getGuid())); + } + + /** + * 同一业务号可能先生成换层阶段,再生成最终出库阶段。换层阶段的中转终点只能作为历史证据, + * 不能覆盖 {@code default02=2} 出库记录携带的真实货物终点。 + */ + @Test + void usesBusinessSnapshotEndpointsWhenTransferEvidenceHasAnIntermediateTarget() { + HistoricalSimulationInput input = new HistoricalSimulationInputFactory().create(dataset); + List matches = input.getTasks().stream() + .filter(task -> "50089".equals(task.getRecord().getTaskNo())) + .collect(java.util.stream.Collectors.toList()); + + assertEquals(1, matches.size()); + HistoricalSimulationTask task = matches.get(0); + assertEquals(DispatchTask.TaskType.OUTBOUND, task.getTaskType()); + assertEquals("030301", task.getPhysicalSource()); + assertEquals("070101", task.getPhysicalTarget()); + assertEquals(16, task.getSnapshotEvidence().size()); + assertEquals(0, input.getFailedInputs().size()); + } + + /** 防止同一业务号出现相互矛盾的入出库编码时被任意归类并进入调度。 */ + @Test + void rejectsConflictingBusinessCodesForOneParentTask() throws Exception { + HistoricalQueueRecord inbound = record("1", "CONFLICT-A"); + HistoricalQueueRecord outbound = record("2", "CONFLICT-B"); + HistoricalQueueDataset changed = copyWithRecords(Arrays.asList(inbound, outbound)); + + HistoricalSimulationInput input = new HistoricalSimulationInputFactory().create(changed); + + assertEquals(0, input.getTasks().size()); + assertEquals(1, input.getFailedInputs().size()); + assertEquals("CONFLICTING_HISTORY_SNAPSHOTS:BUSINESS_TYPE", + input.getFailedInputs().get(0).getReason()); + } + + private static long count(HistoricalSimulationInput input, DispatchTask.TaskType type) { + return input.getTasks().stream().filter(task -> task.getTaskType() == type).count(); + } + + private HistoricalQueueRecord record(String default02, String guid) throws Exception { + String value = "{" + + "\"snapshot_row\":\"1\"," + + "\"guid\":\"" + guid + "\"," + + "\"task_no\":\"CONFLICT\"," + + "\"task_type\":\"2\"," + + "\"priority\":2," + + "\"cargo_pick_site\":\"050101\"," + + "\"cargo_place_site\":\"030401\"," + + "\"start_site\":\"050101\"," + + "\"end_site\":\"030401\"," + + "\"path_info\":\"050101,040101,030401\"," + + "\"create_date\":\"2026-03-04 08:50:33\"," + + "\"finish_date\":\"2026-03-04 08:52:33\"," + + "\"agv_id\":\"1\"," + + "\"default02\":\"" + default02 + "\"}" + ; + return JSON.readValue(value, HistoricalQueueRecord.class); + } + + private HistoricalQueueDataset copyWithRecords(List records) { + return new HistoricalQueueDataset(records, dataset.getMapSiteDetails(), + dataset.getFullMapCells(), dataset.getFullMapRows(), dataset.getFullMapSha256(), + records.size(), dataset.getFullScanTotalRows(), dataset.getDatabase(), + dataset.getSourceTable(), dataset.getExtractedAt(), dataset.getMinCreateDate(), + dataset.getMaxCreateDate(), dataset.getMinFinishDate(), dataset.getMaxFinishDate(), + dataset.getMissingPathRows(), dataset.getMissingIntervalRows(), + dataset.getReversedIntervalRows(), dataset.getChannelFrontRecords()); + } +} diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInputFactoryTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInputFactoryTest.java index 45f083d..0c5cef8 100644 --- a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInputFactoryTest.java +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInputFactoryTest.java @@ -9,16 +9,19 @@ import org.junit.jupiter.api.Test; import java.util.Arrays; import java.util.ArrayList; +import java.util.Collections; import java.util.EnumSet; import java.util.List; import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; class HistoricalSimulationInputFactoryTest { private static HistoricalSimulationInput input; + private static HistoricalSimulationInput rawInput; /** * 验证“create input”场景,确保相关业务规则和断言成立。 @@ -29,7 +32,9 @@ class HistoricalSimulationInputFactoryTest { static void createInput() { HistoricalQueueDataset dataset = new HistoricalQueueLoader().load( HistoricalSimulationInputFactoryTest.class.getClassLoader(), "shadow/history"); - input = new HistoricalSimulationInputFactory().create(dataset); + HistoricalSimulationInputFactory factory = new HistoricalSimulationInputFactory(); + input = factory.create(dataset); + rawInput = factory.createRawSnapshots(dataset); } /** @@ -38,11 +43,21 @@ class HistoricalSimulationInputFactoryTest { *

该方法属于 {@code HistoricalSimulationInputFactoryTest},用于保持当前模块的职责边界清晰。 */ @Test - void convertsEveryHistoryRowToOneUniqueSchedulableOrFailedInput() { - assertEquals(240, input.getTasks().size()); + void reconstructsBusinessTasksAndPreservesRawSnapshotMode() { + assertEquals(SimulationExecutionMode.SOURCE_ALIGNED_BUSINESS, + input.getExecutionMode()); + assertEquals(76, input.getTasks().size()); assertEquals(0, input.getFailedInputs().size()); - assertEquals(240, input.allSimulationIds().size()); - assertEquals(240, input.allSimulationIds().stream().distinct().count()); + assertEquals(76, input.allSimulationIds().size()); + assertEquals(240, input.getInputAudit().getRawSnapshotCount()); + assertEquals(98, input.getInputAudit().getUniqueQueueCount()); + assertEquals(142, input.getInputAudit().getDuplicateSnapshotCount()); + assertEquals(76, input.getInputAudit().getBusinessTaskCount()); + assertEquals(17, input.getInputAudit().getOperationalQueueCount()); + + assertEquals(SimulationExecutionMode.RAW_SNAPSHOT, rawInput.getExecutionMode()); + assertEquals(240, rawInput.getTasks().size()); + assertEquals(240, rawInput.allSimulationIds().stream().distinct().count()); } /** @@ -52,12 +67,12 @@ class HistoricalSimulationInputFactoryTest { */ @Test void preservesBusinessInputsWhileMappingPhysicalEndpointsExplicitly() { - HistoricalSimulationTask task = input.getTasks().stream() + HistoricalSimulationTask task = rawInput.getTasks().stream() .filter(item -> "tsj".equals(item.getRecord().getEndSite())) .findFirst().get(); assertEquals("tsj", task.getRecord().getEndSite()); assertFalse("tsj".equals(task.getPhysicalTarget())); - assertEquals("07050" + input.getGridMap().getSite(task.getPhysicalSource()).getFloor(), + assertEquals("07050" + rawInput.getGridMap().getSite(task.getPhysicalSource()).getFloor(), task.getPhysicalTarget()); assertFalse(task.getHistoricalNodes().isEmpty()); } @@ -81,6 +96,24 @@ class HistoricalSimulationInputFactoryTest { assertFalse(input.getGridMap().getNeighbors("070501").contains("070502")); } + /** A/B 审计任务也必须使用业务阶段模式,并继承生产货位的通道前端配置。 */ + @Test + void pointToPointInputUsesSourceAlignedModeAndChannelFronts() { + HistoricalSimulationInput pointToPoint = new PointToPointSimulationInputFactory().create( + input, "050101", "030401", DispatchTask.TaskType.INBOUND, + 2, null, Collections.emptyList()); + + assertEquals(SimulationExecutionMode.SOURCE_ALIGNED_BUSINESS, + pointToPoint.getExecutionMode()); + assertEquals(1, pointToPoint.getInputAudit().getBusinessTaskCount()); + assertEquals(1, pointToPoint.getTasks().size()); + HistoricalSimulationTask task = pointToPoint.getTasks().get(0); + assertNull(task.getSourceFrontSite()); + assertEquals("050401", task.getTargetFrontSite()); + assertEquals("050401", + pointToPoint.getChannelFrontTopology().frontFor("030401").get()); + } + /** * 验证“changing historical path does not change planning input”场景,确保相关业务规则和断言成立。 * @@ -98,8 +131,10 @@ class HistoricalSimulationInputFactoryTest { "FAKE_A,FAKE_B", first.getCreateDate(), first.getFinishDate(), first.getAgvId())); HistoricalQueueDataset changed = copyWithRecords(original, changedRecords); - HistoricalSimulationInput originalInput = new HistoricalSimulationInputFactory().create(original); - HistoricalSimulationInput changedInput = new HistoricalSimulationInputFactory().create(changed); + HistoricalSimulationInput originalInput = + new HistoricalSimulationInputFactory().createRawSnapshots(original); + HistoricalSimulationInput changedInput = + new HistoricalSimulationInputFactory().createRawSnapshots(changed); assertEquals(originalInput.getTasks().size(), changedInput.getTasks().size()); assertEquals(originalInput.getFailedInputs().size(), changedInput.getFailedInputs().size()); @@ -119,8 +154,8 @@ class HistoricalSimulationInputFactoryTest { first.getTaskType(), first.getPriority(), "UNKNOWN_SITE", first.getEndSite(), first.getPathInfo(), first.getCreateDate(), first.getFinishDate(), first.getAgvId())); - HistoricalSimulationInput changed = new HistoricalSimulationInputFactory().create( - copyWithRecords(original, changedRecords)); + HistoricalSimulationInput changed = new HistoricalSimulationInputFactory() + .createRawSnapshots(copyWithRecords(original, changedRecords)); assertEquals(239, changed.getTasks().size()); assertEquals(1, changed.getFailedInputs().size());