diff --git a/docs/superpowers/plans/2026-09-15-ft546wms-takeover-readiness.md b/docs/superpowers/plans/2026-09-15-ft546wms-takeover-readiness.md new file mode 100644 index 0000000..4760983 --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-ft546wms-takeover-readiness.md @@ -0,0 +1,105 @@ +# FT546WMS Takeover Readiness Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Produce a testable candidate that reports equivalent outbound failures correctly, refreshes reliably, and never dispatches a blocked rear outbound before its relocation completes. + +**Architecture:** Keep Legacy as the production executor while hardening observation and dispatch safety. Separate refresh timeout from request comparison timeout; classify outbound comparisons by business meaning; enforce the relocation dependency both before WMS HTTP dispatch and, where the WCS contract permits, before WCS execution. ACTIVE takeover remains disabled until a dedicated mutation adapter and field evidence exist. + +**Tech Stack:** Java 17, Spring Boot, MyBatis, SQL Server 2016, JUnit 5, Mockito, PowerShell integration fixtures. + +**Spec:** User instruction in this thread: relocation precedes associated outbound; completion and inventory update release outbound; WCS must guard bypasses; unrelated channels continue normally. + +## Global Constraints + +- No production switch, restart, synthetic production task, or business-data mutation during implementation. +- Existing mixed-pilot lane scope and Legacy allocation behavior remain unchanged. +- Every behavior change follows red-green testing and is independently revertible. +- SQL Server 2016 syntax only; no online dependency downloads are assumed. + +--- + +### Task 1: Outbound comparison semantics + +**Files:** +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java` + +**Interfaces:** +- Consumes: `OutboundShadowObservation.resultCode()` and Legacy numeric result code. +- Produces: stable `MATCH`, `DIFFERENT`, `NOT_COMPARABLE` comparison codes while retaining raw evidence in `reason`. + +- [ ] Add a failing test proving shadow `ERROR_STOCK_NOT_FOUND` plus Legacy `3` is an equivalent no-inventory outcome, and successful/technical/skipped cases are not falsely matched. +- [ ] Run only `ShadowPlanningEngineTest` and observe the exact `LEGACY_3` failure. +- [ ] Add a private outbound comparison classifier; do not alter planning or Legacy execution. +- [ ] Re-run focused and shadow-module tests. +- [ ] Commit independently. + +### Task 2: Dedicated refresh timeout + +**Files:** +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowConfigurationTest.java` +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowObservationProperties.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuard.java` +- Modify: `ruoyi-admin/src/main/resources/application.yml` + +**Interfaces:** +- Consumes: `FORTUCKY_SHADOW_REFRESH_TIMEOUT_SECONDS`, default 10 seconds. +- Produces: a refresh-only transaction timeout; the 3-second Legacy comparison deadline stays unchanged. + +- [ ] Add failing property/guard tests and a transaction-manager test that distinguishes planning timeout from refresh timeout. +- [ ] Run focused tests and observe missing property/incorrect timeout failure. +- [ ] Use separate transaction templates or per-refresh timeout without sharing mutable template state. +- [ ] Re-run focused and shadow-module tests. +- [ ] Commit independently. + +### Task 3: WMS dispatch dependency gate + +**Files:** +- Create or modify focused mapper/service files under `ruoyi-admin/src/main/java/com/ruoyi/api` and `ruoyi-admin/src/main/resources/mapper` after locating the existing job mapper contract. +- Create: focused JUnit test for `ToWcsServiceImpl.pushOutMydoJob()`. +- Modify: `scripts/mixed-pilot/tests/behavior.sql` only if an SQL regression is required by the confirmed race. + +**Interfaces:** +- Consumes: candidate outbound job plus current lane obstruction / unresolved relocation state. +- Produces: dispatch decision `READY` or `WAITING_FOR_RELOCATION`; only READY calls WCS and advances status. + +- [ ] Add a failing service test: blocked rear outbound makes zero HTTP calls, unrelated/front outbounds still dispatch, and completing relocation makes the rear job dispatch exactly once. +- [ ] Reproduce the task-creation race in the SQL scratch fixture if it bypasses the existing status-8 trigger. +- [ ] Add the smallest read-only pre-dispatch guard and atomic claim/update boundary supported by current service patterns. +- [ ] Re-run Java and SQL mixed-pilot regression suites. +- [ ] Commit independently. + +### Task 4: WCS defense and release candidate + +**Files:** +- Modify only the actual FT546-WCS receiving/execution module identified by repository audit. +- Add focused WCS tests at its receive or execution seam. +- Add deployment and rollback notes under `scripts/` in each affected repository. + +**Interfaces:** +- Consumes: WMS job payload and WCS current/declared dependency evidence. +- Produces: explicit accepted/waiting/rejected behavior without treating HTTP receipt as physical completion. + +- [ ] Add a failing WCS test proving a blocked rear task cannot enter executable state, including manual/replayed payloads. +- [ ] Implement the minimal defense compatible with the existing WMS/WCS contract; if WCS lacks authoritative obstruction data, reject ambiguous rear tasks rather than inventing state. +- [ ] Run both repository test suites and inspect package contents offline. +- [ ] Prepare versioned artifacts and rollback scripts; do not deploy or switch without a maintenance decision. +- [ ] Request independent code review and address findings. + +### Task 5: ACTIVE takeover gate + +**Files:** +- No production code change until Tasks 1-4 and field evidence pass. +- Update readiness documentation with exact remaining mutation adapter and rollback requirements. + +**Interfaces:** +- Consumes: verified planner result and explicit operator mode. +- Produces: a future single-writer execution path, never simultaneous Legacy and algorithm allocation. + +- [ ] Preserve `ShadowSafetyGuard` rejection of ACTIVE in this candidate. +- [ ] Define endpoint-by-endpoint single-writer tests before adding any ACTIVE mutation adapter. +- [ ] Require fresh inbound, successful outbound, no-inventory outbound, refresh and relocation field evidence before implementation approval. +- [ ] Do not claim formal takeover readiness merely because the candidate builds. diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java index f55372c..48337c2 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java @@ -236,7 +236,7 @@ public class ShadowPlanningEngine { int inserted = inputMapper.insertComparison( UUID.randomUUID().toString(), observation.warehouseId(), "OUTBOUND_COMPARE", observation.snapshotGeneration(), observation.snapshotSynchronizedAt(), - observation.sourceSignature(), "LEGACY_" + nullSafe(legacyResultCode), + observation.sourceSignature(), outboundComparisonCode(observation, legacyResultCode), outboundComparisonReason(observation, legacyResultCode)); requireComparisonInserted(inserted); }); @@ -600,6 +600,15 @@ public class ShadowPlanningEngine { ? result + "_CONSERVATIVE" : result; } + private String outboundComparisonCode(OutboundShadowObservation observation, String legacyCode) { + if (observation.observed() + && "ERROR_STOCK_NOT_FOUND".equals(observation.resultCode()) + && "3".equals(legacyCode)) { + return "MATCH_NO_INVENTORY"; + } + return "LEGACY_" + nullSafe(legacyCode); + } + private boolean isBlank(String value) { return value == null || value.isBlank(); } diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java index dac75a9..adec5e0 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java @@ -211,6 +211,43 @@ class ShadowPlanningEngineTest { eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), eq("LEGACY_10"), any()); } + @Test + void outboundComparisonMatchesWhenBothPlannersReportNoInventory() { + ShadowInputMapper mapper = mock(ShadowInputMapper.class); + when(mapper.insertComparison(any(), any(), any(), any(Long.class), + any(Instant.class), any(), any(), any())).thenReturn(1); + ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper); + OutboundShadowObservation observation = OutboundShadowObservation.unavailable( + SNAPSHOT, "DETAIL-1", "ERROR_STOCK_NOT_FOUND", "no matching inventory"); + + engine.compareOutbound(observation, "3"); + + verify(mapper).insertComparison(any(), eq("01"), eq("OUTBOUND_COMPARE"), eq(42L), + eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), eq("MATCH_NO_INVENTORY"), + argThat(reason -> reason.contains("shadow=ERROR_STOCK_NOT_FOUND") + && reason.contains("legacy=3"))); + } + + @Test + void outboundComparisonDoesNotMatchNoInventoryAgainstTechnicalOrDifferentOutcomes() { + ShadowInputMapper mapper = mock(ShadowInputMapper.class); + when(mapper.insertComparison(any(), any(), any(), any(Long.class), + any(Instant.class), any(), any(), any())).thenReturn(1); + ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper); + OutboundShadowObservation technical = OutboundShadowObservation.unavailable( + SNAPSHOT, "DETAIL-1", "ERROR_TECHNICAL", "database failure"); + OutboundShadowObservation noInventory = OutboundShadowObservation.unavailable( + SNAPSHOT, "DETAIL-2", "ERROR_STOCK_NOT_FOUND", "no matching inventory"); + + engine.compareOutbound(technical, "3"); + engine.compareOutbound(noInventory, "10"); + + verify(mapper).insertComparison(any(), eq("01"), eq("OUTBOUND_COMPARE"), eq(42L), + eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), eq("LEGACY_3"), any()); + verify(mapper).insertComparison(any(), eq("01"), eq("OUTBOUND_COMPARE"), eq(42L), + eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), eq("LEGACY_10"), any()); + } + @Test void inboundComparisonRetainsRequiredEvidenceWhenUnicodeReasonIsOversized() { ShadowInputMapper mapper = mock(ShadowInputMapper.class);