Files
FT546-WCS/docs/superpowers/plans/2026-08-04-standard-wcs-ft546-adapters.md

516 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Standard WCS FT546 Adapters 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:** 用 FT546 Admin 适配器把现有 WMS URL、旧 PLC 寄存器、设备清单和 RuoYi 页面接入标准 `ruoyi-wcs`,并让标准运行入口取代旧调度线程的业务所有权。
**Architecture:** `ruoyi-admin` 下新增 `com.ruoyi.project.ft546`,保存 WMS、设备协议、配置、运行 wiring 和页面 Controller。现有 `PlcSrgvRead/PlcTsjRead/PlcSsjRead/PlcCdzRead` 只保留通信与事实采集职责;核心端口实现调用它们,但状态裁决全部回到 `ruoyi-wcs`。
**Tech Stack:** Java 8, RuoYi, Spring MVC, Spring scheduling, HslCommunication Modbus, PLC4J, MyBatis, Vue 2, Element UI.
## Global Constraints
- 保留 `/wcs2Wms/api/receiveOutMydoJob`、`receiveInMydoJob`、`cancelMydoJob` 和 `tidyVehicleSnapshot` URL。
- 保留 FT546 现有 PLC 地址和寄存器语义,不在核心出现 `4000/4400/4068` 等地址。
- `Ft546LegacySrgvAdapter` 必须按能力 Profile 限制命令;旧 PLC 不支持命令幂等和 Prepare/Commit。
- 旧队列、`default02-default08` 和 `mydo_job_wcs` 只作为旧接口证据,不成为新核心状态。
- 一个设备同一时刻只允许一个运动命令;`UNKNOWN` 时只读快照。
- 项目配置在启动时校验;有活动命令时禁止切换 Profile。
- `BaseSRgvThread`、`TsjThread` 和 `CdzThread` 不再创建、调度或完成标准任务。
- UI 只调用 QueryService 和 RecoveryUseCase,不直接编辑核心表。
---
### Task 1: Add FT546 project configuration and capability profiles
**Files:**
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546WcsProperties.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546DeviceProfile.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546ConfigurationValidator.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546CapabilityGate.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546CapabilityEvidence.java`
- Create: `ruoyi-admin/src/main/resources/ft546/wcs-device-profiles.yml`
- Create: `sql/wcs/V004__ft546_project_adapter_config.sql`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/project/ft546/config/Ft546ConfigurationValidatorTest.java`
**Interfaces:**
- Produces: `Ft546WcsProperties.getRuntimeMode/getDispatchIntervalMs/getCommandPollIntervalMs`.
- Produces: device profile fields `deviceId`, `deviceType`, `adapterType`, `ip`, `port`, `station`, `capabilities`, `registers`, `profileVersion`.
- Persists Admin-owned `project_device_endpoint`, `project_plc_address`, `project_protocol_profile`, `project_protocol_capability_evidence`, `project_wms_mapping`.
- `Ft546CapabilityGate.evaluate(profile,evidence): CapabilityGateResult` defaults `TWO_STAGE_ATOMIC_TASK` to disabled and permits it only when evidence names the exact profile version, corpus manifest hash, passing test run ID, Git commit, and approval time.
- [ ] **Step 1: Write failing profile-validation tests**
```java
@Test
void rejectsLegacyProfileThatClaimsPrepareCommit() {
Ft546DeviceProfile profile = legacySrgvProfile();
profile.getCapabilities().add("PREPARE_COMMIT_SEGMENT");
assertThrows(IllegalArgumentException.class, () -> validator.validate(profile));
}
@Test
void rejectsDuplicateRegisterOwnershipWithinOneDevice() {
assertThrows(IllegalArgumentException.class, () ->
validator.validate(profileWithDuplicateAddress("4000")));
}
@Test
void rejectsTwoStageCapabilityWithoutPassingEvidenceForTheExactProfile() {
Ft546DeviceProfile profile = legacySrgvProfileWith("TWO_STAGE_ATOMIC_TASK");
assertFalse(capabilityGate.evaluate(profile, noEvidence()).isPublishable());
assertEquals("TWO_STAGE_EVIDENCE_REQUIRED",
capabilityGate.evaluate(profile, noEvidence()).getReasonCode());
}
@Test
void legacyProfileKeepsUnsupportedCapabilitiesDisabled() {
CapabilityGateResult result = capabilityGate.evaluate(legacySrgvProfile(), noEvidence());
assertFalse(result.isEnabled("COMMAND_RESULT_QUERY"));
assertFalse(result.isEnabled("ORDERED_DEVICE_SNAPSHOT"));
assertFalse(result.isEnabled("CANDIDATE_BUFFER"));
assertFalse(result.isEnabled("PREPARE_COMMIT_SEGMENT"));
assertFalse(result.isEnabled("IDEMPOTENT_COMMAND_KEY"));
assertFalse(result.isEnabled("BOOT_SESSION_ID"));
}
```
- [ ] **Step 2: Run RED**
Run: `mvn -pl ruoyi-admin -am -Dtest=Ft546ConfigurationValidatorTest test`
- [ ] **Step 3: Implement strongly typed configuration**
The checked-in profile contains no credentials and declares:
```yaml
devices:
- deviceId: "1"
deviceType: SHUTTLE
adapterType: FT546_LEGACY_SRGV
profileVersion: 1
capabilities: [POINT_TO_POINT_COMMAND]
registers:
taskNo: "4000"
taskType: "4002"
path: "4004"
trigger: "4021"
status: "4068"
execution: "4060"
```
IP, port, station and WMS URLs come from database/environment configuration, not the checked-in YAML.
Persist capability evidence separately from the profile. Publishing or activating a profile fails closed when evidence is absent, expired, belongs to another profile version/hash, or reports any protocol regression failure. Active-command devices cannot change profile or evidence binding.
- [ ] **Step 4: Run GREEN**
Run: `mvn -pl ruoyi-admin -am -Dtest=Ft546ConfigurationValidatorTest test`
- [ ] **Step 5: Commit**
```bash
git add ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config ruoyi-admin/src/main/resources/ft546 sql/wcs/V004__ft546_project_adapter_config.sql ruoyi-admin/src/test/java/com/ruoyi/project/ft546/config
git commit -m "feat(ft546): add WCS adapter configuration profiles"
```
### Task 2: Replace fixed shuttle readers with a device runtime registry
**Files:**
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/Ft546DeviceRuntimeRegistry.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546SrgvRuntimeFactory.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/schedule/ScheduleThread.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead/srgv/PlcSrgvRead.java`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/Ft546DeviceRuntimeRegistryTest.java`
**Interfaces:**
- Produces: `register(String deviceId, PlcSrgvRead runtime)`, `requireSrgv(String deviceId)`, `allSrgv()`.
- Produces: factory `create(DeviceSrgv,Ft546DeviceProfile): PlcSrgvRead`.
- Removes new-code dependence on fixed `srgv1Read/srgv2Read/srgv3Read` switches.
- [ ] **Step 1: Write a failing configurable-count test**
```java
@Test
void registersAnyConfiguredNumberOfShuttleRuntimes() {
registry.register("1", runtime("1"));
registry.register("2", runtime("2"));
registry.register("9", runtime("9"));
assertEquals(3, registry.allSrgv().size());
assertSame(runtime("9").getClass(), registry.requireSrgv("9").getClass());
}
```
- [ ] **Step 2: Run RED**
Run: `mvn -pl ruoyi-admin -am -Dtest=Ft546DeviceRuntimeRegistryTest test`
- [ ] **Step 3: Implement registry-backed startup**
`ScheduleThread.startAllThread()` loads enabled devices, creates readers through the factory, registers them, and starts only fact-collection threads. It must not start `SRgv1Thread` or any task-creation thread in `STANDARD` mode.
- [ ] **Step 4: Run GREEN and verify no three-vehicle switch remains in new paths**
Run: `mvn -pl ruoyi-admin -am -Dtest=Ft546DeviceRuntimeRegistryTest test`
Run: `rg "case 1|case 2|case 3|srgv1Read|srgv2Read|srgv3Read" ruoyi-admin/src/main/java/com/ruoyi/project/ft546 ruoyi-admin/src/main/java/com/ruoyi/schedule/ScheduleThread.java`
Expected: no fixed vehicle selection in the new project package or Standard startup branch.
- [ ] **Step 5: Commit**
```bash
git add ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device ruoyi-admin/src/main/java/com/ruoyi/schedule/ScheduleThread.java ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead/srgv/PlcSrgvRead.java ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device
git commit -m "refactor(ft546): register shuttle runtimes from configuration"
```
### Task 3: Implement the legacy shuttle Adapter and snapshot mapper
**Files:**
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapter.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546SrgvCommandEncoder.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546SrgvSnapshotMapper.java`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapterTest.java`
- Create: `ruoyi-admin/src/test/resources/ft546/plc/srgv-point-to-point.json`
- Create: `ruoyi-admin/src/test/resources/ft546/plc/srgv-two-stage-gate.json`
**Interfaces:**
- Implements: `DeviceCommandGateway` and `DeviceSnapshotGateway` for `SHUTTLE` devices.
- Consumes: `Ft546DeviceRuntimeRegistry` and validated profile.
- Produces: existing write sequence `4004 -> 4000 -> 4002 -> 4021`, plus configured optional fields.
- [ ] **Step 1: Write failing protocol sequence and UNKNOWN tests**
```java
@Test
void encodesPointToPointWritesInTheExistingOrder() {
SendReceipt receipt = adapter.send(pointToPoint("CMD-1", "1", route("A", "B")));
assertEquals(SendReceipt.Status.ACCEPTED, receipt.getStatus());
assertEquals(Arrays.asList("4004", "4000", "4002", "4021"), fakeRuntime.writtenAddresses());
}
@Test
void writeAmbiguityReturnsAcceptedUnknownInsteadOfRetryableNotSent() {
fakeRuntime.throwAfterWriting("4000");
assertEquals(SendReceipt.Status.ACCEPTED_UNKNOWN,
adapter.send(pointToPoint("CMD-1", "1", route("A", "B"))).getStatus());
}
@Test
void twoStageCommandIsRejectedUntilTheProfileGateEvidencePasses() {
assertThrows(IllegalStateException.class, () ->
adapterFor(twoStageProfile(), noEvidence()));
assertEquals(0, fakeRuntime.writtenAddresses().size());
}
```
- [ ] **Step 2: Run RED**
Run: `mvn -pl ruoyi-admin -am -Dtest=Ft546LegacySrgvAdapterTest test`
- [ ] **Step 3: Implement profile-limited command encoding**
Reject commands other than enabled `POINT_TO_POINT_COMMAND` and evidence-gated `TWO_STAGE_ATOMIC_TASK`. The two-stage gate fixture records the exact legacy write order, trigger semantics, disconnect boundaries, expected certainty, profile version, and manifest hash; it does not claim Prepare/Commit semantics. Never send a new command when the core reports an active `UNKNOWN` command for the device. Map `DeviceSrgv` connection, mode, status, battery, current site, task number, source, target, floor and load facts into the standard snapshot without inventing device sequence or boot ID.
- [ ] **Step 4: Run protocol regression GREEN**
Run: `mvn -pl ruoyi-admin -am -Dtest=Ft546LegacySrgvAdapterTest test`
Expected: JSON sample and register sequence match, and no command completes merely because writes returned success.
- [ ] **Step 5: Commit**
```bash
git add ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/shuttle ruoyi-admin/src/test/resources/ft546/plc/srgv-point-to-point.json
git commit -m "feat(ft546): adapt legacy shuttle PLC protocol"
```
### Task 4: Implement lift, conveyor, and charger Adapters
**Files:**
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/lift/Ft546LiftAdapter.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/conveyor/Ft546ConveyorAdapter.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/charger/Ft546ChargerAdapter.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead/tsj/PlcTsjRead.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead/ssj/PlcSsjRead.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead/cdz/PlcCdzRead.java`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/Ft546EquipmentAdaptersTest.java`
**Interfaces:**
- Implements standard `DeviceCommandGateway`/`DeviceSnapshotGateway` dispatch by device type.
- Maps lift floor/door/port facts, conveyor goods/mode/ready facts, and charger occupancy/charging/fault facts.
- [ ] **Step 1: Write failing fact-mapping and no-business-decision tests**
```java
@Test
void liftAdapterReportsFactsWithoutReleasingCoreResources() {
DeviceSnapshot snapshot = liftAdapter.readSnapshot("TSJ-1");
assertEquals("3", snapshot.fact("floor"));
assertEquals("OPEN", snapshot.fact("doorState"));
assertFalse(snapshot.getFacts().containsKey("releaseLiftResource"));
}
```
- [ ] **Step 2: Run RED**
Run: `mvn -pl ruoyi-admin -am -Dtest=Ft546EquipmentAdaptersTest test`
- [ ] **Step 3: Implement typed protocol translation**
Each adapter validates device type and enabled command, performs only configured writes/reads, and emits standard facts. Remove business entry/exit decisions from new calls to `TsjThread`, `SsjThread`, and `CdzThread`; keep those classes unused in Standard mode.
- [ ] **Step 4: Run GREEN**
Run: `mvn -pl ruoyi-admin -am -Dtest=Ft546EquipmentAdaptersTest,Ft546LegacySrgvAdapterTest test`
- [ ] **Step 5: Commit**
```bash
git add ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device
git commit -m "feat(ft546): adapt lift conveyor and charger protocols"
```
### Task 5: Adapt existing WMS endpoints to standard order use cases
**Files:**
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/wms/Ft546WmsOrderMapper.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/wms/Ft546WmsAdapterService.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/wms/Ft546WmsNotificationGateway.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/api/controller/Wcs2WmsController.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/api/service/impl/ApiServiceImpl.java`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/project/ft546/wms/Ft546WmsAdapterServiceTest.java`
**Interfaces:**
- Maps existing `ReceiveJob` to `CreateTransportOrderCommand`.
- Keeps existing response envelope expected by WMS.
- Implements `WmsNotificationGateway.notify(WmsNotification): WmsNotificationReceipt` by calling the existing `RequestLogService` callback transport.
- [ ] **Step 1: Write failing endpoint mapping and idempotency tests**
```java
@Test
void mapsExistingInboundRequestToCanonicalOrder() {
adapter.receiveInMydoJob(receiveJob("TASK-1", "PORT-1", "SPACE-9"));
CreateTransportOrderCommand command = useCase.lastCreateCommand();
assertEquals("TASK-1", command.getExternalRequestId());
assertEquals(TransportOrderType.INBOUND, command.getOrderType());
assertEquals("PORT-1", command.getSource());
assertEquals("SPACE-9", command.getDestination());
}
@Test
void duplicateWmsRequestReturnsTheSameOrder() {
assertEquals(adapter.receiveOutMydoJob(job).getOrderId(),
adapter.receiveOutMydoJob(job).getOrderId());
}
```
- [ ] **Step 2: Run RED**
Run: `mvn -pl ruoyi-admin -am -Dtest=Ft546WmsAdapterServiceTest test`
- [ ] **Step 3: Delegate the four preserved endpoints**
`Wcs2WmsController` routes `receiveOutMydoJob`, `receiveInMydoJob`, `cancelMydoJob`, and `tidyVehicleSnapshot` to `Ft546WmsAdapterService`. The new path must not insert `mydo_job_wcs` or `wcs_srgv_queue`; unrelated legacy endpoints remain untouched.
- [ ] **Step 4: Implement idempotent completion callback**
Map `OrderCompleted/Failed/Cancelled` to existing callback payloads and use WCS `eventId` as the callback idempotency key. A callback transport failure leaves Outbox pending and never recreates an order.
- [ ] **Step 5: Run GREEN**
Run: `mvn -pl ruoyi-admin -am -Dtest=Ft546WmsAdapterServiceTest test`
- [ ] **Step 6: Commit**
```bash
git add ruoyi-admin/src/main/java/com/ruoyi/project/ft546/wms ruoyi-admin/src/main/java/com/ruoyi/api/controller/Wcs2WmsController.java ruoyi-admin/src/main/java/com/ruoyi/api/service/impl/ApiServiceImpl.java ruoyi-admin/src/test/java/com/ruoyi/project/ft546/wms
git commit -m "feat(ft546): adapt existing WMS contract to WCS core"
```
### Task 6: Wire the Standard runtime and remove legacy scheduling ownership
**Files:**
- Create: `ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/WcsRuntimeCoordinator.java`
- Create: `ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/RuntimeTickResult.java`
- Create: `ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/SnapshotCollector.java`
- Create: `ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/recovery/RecoveryGate.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/runtime/Ft546WcsScheduler.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/runtime/Ft546AdapterConfiguration.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/schedule/ScheduleThread.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/schedule/scheduleTask/srgv/BaseSRgvThread.java`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/project/ft546/runtime/Ft546StandardRuntimeTest.java`
**Interfaces:**
- Produces: `WcsRuntimeCoordinator.tick(): RuntimeTickResult`.
- Scheduler calls snapshot ingest, recovery gate, dispatch, command creation, and Outbox delivery in that order.
- Standard mode has exactly one business scheduler and one Outbox sender.
- Consumes: `DispatchSnapshotProvider.load()` and `DispatchCoordinator.dispatch(DispatchSnapshot)` from Plan 2.
- [ ] **Step 1: Write a failing single-writer test**
```java
@Test
void standardModeStartsCoreRuntimeButNotLegacyBusinessThreads() {
runtime.start();
assertTrue(runtime.isCoreSchedulerStarted());
assertFalse(runtime.isBaseSrgvThreadStarted());
assertFalse(runtime.isTsjBusinessThreadStarted());
assertFalse(runtime.isCdzBusinessThreadStarted());
}
```
- [ ] **Step 2: Run RED**
Run: `mvn -pl ruoyi-admin -am -Dtest=Ft546StandardRuntimeTest test`
- [ ] **Step 3: Implement the runtime tick**
```java
public RuntimeTickResult tick() {
snapshotCollector.collectAndAccept();
if (!recoveryGate.allowsDispatch()) {
return RuntimeTickResult.recoveryBlocked();
}
DispatchCycleResult dispatch = dispatchCoordinator.dispatch(dispatchSnapshotProvider.load());
for (GrantedDispatch granted : dispatch.getGranted()) {
executionService.createCommand(granted);
}
OutboxDispatchResult outbox = outboxDispatcher.dispatchDue(100, clock.now());
return RuntimeTickResult.of(dispatch, outbox);
}
```
`ScheduleThread` in Standard mode starts PLC fact readers only. Remove standard-mode calls that instantiate or start `SRgv1Thread`, `TsjThread`, `SsjThread`, or `CdzThread`. Keep legacy code compilable but unreachable from Standard startup.
- [ ] **Step 4: Run single-writer and reactor tests GREEN**
Run: `mvn -pl ruoyi-admin -am -Dtest=Ft546StandardRuntimeTest,Ft546WmsAdapterServiceTest,Ft546LegacySrgvAdapterTest test`
Run: `rg "new SRgv1Thread|new TsjThread|new CdzThread" ruoyi-admin/src/main/java/com/ruoyi/project/ft546 ruoyi-admin/src/main/java/com/ruoyi/schedule/ScheduleThread.java`
Expected: Standard branch contains no legacy business thread construction.
- [ ] **Step 5: Commit**
```bash
git add ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/WcsRuntimeCoordinator.java ruoyi-admin/src/main/java/com/ruoyi/project/ft546/runtime ruoyi-admin/src/main/java/com/ruoyi/schedule/ScheduleThread.java ruoyi-admin/src/main/java/com/ruoyi/schedule/scheduleTask/srgv/BaseSRgvThread.java ruoyi-admin/src/test/java/com/ruoyi/project/ft546/runtime
git commit -m "feat(ft546): run standard WCS as the single scheduler"
```
### Task 7: Add operations controllers and Vue monitoring pages
**Files:**
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/web/WcsOrderController.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/web/WcsDeviceController.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/web/WcsTrafficController.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/web/WcsRecoveryController.java`
- Create: `ruoyi-ui/src/api/wcs/order.js`
- Create: `ruoyi-ui/src/api/wcs/device.js`
- Create: `ruoyi-ui/src/api/wcs/traffic.js`
- Create: `ruoyi-ui/src/api/wcs/recovery.js`
- Create: `ruoyi-ui/src/views/wcs/order/index.vue`
- Create: `ruoyi-ui/src/views/wcs/device/index.vue`
- Create: `ruoyi-ui/src/views/wcs/traffic/index.vue`
- Create: `ruoyi-ui/src/views/wcs/recovery/index.vue`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/project/ft546/web/WcsOperationsControllerTest.java`
**Interfaces:**
- GET `/wcs/orders`, `/wcs/devices`, `/wcs/traffic/resources`, `/wcs/recovery/cases`.
- POST `/wcs/orders/{id}/suspend`, `/resume`, `/cancel`.
- POST `/wcs/recovery/cases/{id}/confirm` and `/abort` with operator reason/evidence.
- [ ] **Step 1: Write failing controller authorization and DTO tests**
```java
@Test
@WithMockUser(authorities = "wcs:recovery:confirm")
void confirmsRecoveryThroughTheUseCase() throws Exception {
mockMvc.perform(post("/wcs/recovery/cases/RC-1/confirm")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"reason\":\"现场确认车辆位于070501\",\"currentSite\":\"070501\",\"loaded\":false}"))
.andExpect(status().isOk());
verify(recoveryUseCase).confirmFacts(eq("RC-1"), anyString(), any(RecoveryEvidence.class));
}
```
- [ ] **Step 2: Run RED**
Run: `mvn -pl ruoyi-admin -am -Dtest=WcsOperationsControllerTest test`
- [ ] **Step 3: Implement controllers and API clients**
Controllers return stable QueryService DTOs and use RuoYi permissions. Vue pages provide filters, tables, state tags, detail drawers, pagination and explicit recovery dialogs; they do not include generic CRUD edit/delete for core records.
- [ ] **Step 4: Implement the traffic view without editing core state**
Show resource key, type, capacity, protected count, holder, reservation state and evidence ID. Only Recovery page exposes controlled confirm/abort commands.
- [ ] **Step 5: Run backend and frontend verification**
Run: `mvn -pl ruoyi-admin -am -Dtest=WcsOperationsControllerTest test`
Run: `cd ruoyi-ui && npm run build:prod`
- [ ] **Step 6: Commit**
```bash
git add ruoyi-admin/src/main/java/com/ruoyi/project/ft546/web ruoyi-admin/src/test/java/com/ruoyi/project/ft546/web ruoyi-ui/src/api/wcs ruoyi-ui/src/views/wcs
git commit -m "feat(ft546): add WCS operations console"
```
### Task 8: Add menus, permissions, and the FT546 end-to-end contract test
**Files:**
- Create: `sql/wcs/V005__ft546_wcs_menus_and_permissions.sql`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/project/ft546/Ft546StandardWcsEndToEndTest.java`
- Create: `ruoyi-admin/src/test/resources/ft546/scenarios/inbound-same-floor.json`
- Create: `ruoyi-admin/src/test/resources/ft546/scenarios/outbound-cross-floor.json`
**Interfaces:**
- Permissions: `wcs:order:list`, `wcs:order:control`, `wcs:device:list`, `wcs:traffic:list`, `wcs:recovery:list`, `wcs:recovery:confirm`.
- End-to-end test uses fake PLC runtimes and fake WMS callback transport through real Spring wiring.
- [ ] **Step 1: Write the failing same-floor scenario**
```java
@Test
void completesInboundThroughExistingWmsUrlAndLegacyShuttleAdapter() throws Exception {
String orderId = postInboundScenario("inbound-same-floor.json");
runTicksUntilTerminal(orderId, 200);
assertEquals("COMPLETED", operations.order(orderId).getOrderState());
assertEquals(1, callbackTransport.businessEffectCount(orderId));
assertEquals(0, invariantMonitor.totalViolations());
}
```
- [ ] **Step 2: Run RED**
Run: `mvn -pl ruoyi-admin -am -Dtest=Ft546StandardWcsEndToEndTest test`
- [ ] **Step 3: Add permission SQL and scenario fixtures**
Insert one WCS menu group and four child pages using existing RuoYi `sys_menu` conventions. Use idempotent `IF NOT EXISTS` checks keyed by permission string.
- [ ] **Step 4: Make same-floor and cross-floor scenarios GREEN**
The cross-floor test must prove source port, lift and target port joint reservation; WMS receives one completion event; no legacy queue row is created.
Run: `mvn -pl ruoyi-admin -am -Dtest=Ft546StandardWcsEndToEndTest test`
- [ ] **Step 5: Run full Plan 4 verification**
Run: `mvn -pl ruoyi-admin -am test`
Run: `cd ruoyi-ui && npm run build:prod`
Run: `git diff --check`
- [ ] **Step 6: Commit**
```bash
git add sql/wcs/V005__ft546_wcs_menus_and_permissions.sql ruoyi-admin/src/test/java/com/ruoyi/project/ft546/Ft546StandardWcsEndToEndTest.java ruoyi-admin/src/test/resources/ft546/scenarios
git commit -m "test(ft546): verify standard WCS end to end"
```