|
@@ -0,0 +1,275 @@
|
|
|
|
|
+package com.zksy.web.controller.cockpit;
|
|
|
|
|
+
|
|
|
|
|
+import com.zksy.cockpit.foundation.CockpitDeviceNotFoundException;
|
|
|
|
|
+import com.zksy.cockpit.foundation.CockpitReadException;
|
|
|
|
|
+import com.zksy.cockpit.workorder.CockpitWorkOrderEventAssociation;
|
|
|
|
|
+import com.zksy.cockpit.workorder.CockpitWorkOrderReadAdapter;
|
|
|
|
|
+import com.zksy.cockpit.workorder.CockpitWorkOrderReadService;
|
|
|
|
|
+import com.zksy.cockpit.workorder.CockpitWorkOrderReadSnapshot;
|
|
|
|
|
+import com.zksy.cockpit.workorder.CockpitWorkOrderState;
|
|
|
|
|
+import com.zksy.cockpit.workorder.CockpitWorkOrderStateDictionary;
|
|
|
|
|
+import com.zksy.cockpit.workorder.CockpitWorkOrderSummary;
|
|
|
|
|
+import com.zksy.cockpit.workorder.CockpitWorkOrderTimelineEntry;
|
|
|
|
|
+import com.zksy.cockpit.workorder.CockpitWorkOrderTimelineSnapshot;
|
|
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+import org.springframework.test.web.servlet.MockMvc;
|
|
|
|
|
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|
|
|
|
+
|
|
|
|
|
+import java.lang.reflect.Method;
|
|
|
|
|
+import java.time.Clock;
|
|
|
|
|
+import java.time.Instant;
|
|
|
|
|
+import java.time.ZoneOffset;
|
|
|
|
|
+import java.util.Arrays;
|
|
|
|
|
+import java.util.Collections;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+
|
|
|
|
|
+import static org.hamcrest.Matchers.hasSize;
|
|
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
|
|
+import static org.mockito.Mockito.verifyNoInteractions;
|
|
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
|
|
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
|
|
|
|
+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 CockpitWorkOrderReadControllerTest {
|
|
|
|
|
+ private static final String DEVICE_CODE = "TC-PS-WS-202609020012";
|
|
|
|
|
+
|
|
|
|
|
+ private CockpitWorkOrderReadAdapter adapter;
|
|
|
|
|
+ private MockMvc mockMvc;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ void setUp() {
|
|
|
|
|
+ adapter = mock(CockpitWorkOrderReadAdapter.class);
|
|
|
|
|
+ CockpitWorkOrderReadService service = new CockpitWorkOrderReadService(
|
|
|
|
|
+ adapter,
|
|
|
|
|
+ Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
|
|
|
|
|
+ mockMvc = MockMvcBuilders
|
|
|
|
|
+ .standaloneSetup(new CockpitWorkOrderReadController(service))
|
|
|
|
|
+ .build();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void deviceWorkOrdersReturnNineStateDictionaryAndAuthoritativeMapping() throws Exception {
|
|
|
|
|
+ when(adapter.loadByDevice(DEVICE_CODE)).thenReturn(nineStateFixture());
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/work-orders")
|
|
|
|
|
+ .header("X-Request-Id", "work-order-device-001"))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.requestId").value("work-order-device-001"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.apiVersion").value("v1"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.readOnly").value(true))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.deviceCode").value(DEVICE_CODE))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.stateDictionary", hasSize(9)))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.stateDictionary[0].legacyCode").value(1))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.stateDictionary[0].legacyName").value("待派单"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.stateDictionary[5].legacyCode").value(6))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.stateDictionary[5].legacyName").value("已结案"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.stateDictionary[6].legacyCode").value(7))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.stateDictionary[6].legacyName").value("已驳回"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.stateDictionary[6].authoritativeCode").value(7))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.stateDictionary[6].authoritativeName").value("已取消"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.stateDictionary[7].deprecated").value(true))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.stateDictionary[8].deprecated").value(true))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.workOrders", hasSize(9)))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.workOrders[0].legacyState").value(1))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.workOrders[0].authoritativeState").value(1))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.workOrders[8].legacyState").value(9))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.workOrders[8].authoritativeState").doesNotExist());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void eventWorkOrdersPreserveAssociationsAndSeparateEventFamilies() throws Exception {
|
|
|
|
|
+ when(adapter.loadByEvent("ALARM", "ALM-20260902-001")).thenReturn(singleAlarmFixture());
|
|
|
|
|
+ when(adapter.loadByEvent("WARNING", "WRN-20260902-001")).thenReturn(singleWarningFixture());
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/events/ALARM/ALM-20260902-001/work-orders")
|
|
|
|
|
+ .header("X-Request-Id", "work-order-alarm-001"))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.data.eventSource").value("ALARM"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.eventCode").value("ALM-20260902-001"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.workOrders", hasSize(1)))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.workOrders[0].eventAssociation.eventSource").value("ALARM"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.workOrders[0].eventAssociation.eventCode").value("ALM-20260902-001"));
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/events/WARNING/WRN-20260902-001/work-orders")
|
|
|
|
|
+ .header("X-Request-Id", "work-order-warning-001"))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.data.eventSource").value("WARNING"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.eventCode").value("WRN-20260902-001"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.workOrders", hasSize(1)))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.workOrders[0].eventAssociation.eventSource").value("WARNING"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.workOrders[0].eventAssociation.eventCode").value("WRN-20260902-001"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void sameDeviceDifferentAlarmTypesCanHaveSeparateWorkOrders() throws Exception {
|
|
|
|
|
+ when(adapter.loadByDevice(DEVICE_CODE)).thenReturn(new CockpitWorkOrderReadSnapshot(
|
|
|
|
|
+ DEVICE_CODE,
|
|
|
|
|
+ null,
|
|
|
|
|
+ null,
|
|
|
|
|
+ Arrays.asList(
|
|
|
|
|
+ summary("WO-2026090200101", 4, "ALM-WS_ENV_HIGH-001"),
|
|
|
|
|
+ summary("WO-2026090200102", 5, "ALM-WS_ENV_PH-002"))));
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/work-orders"))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.data.workOrders", hasSize(2)))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.workOrders[0].eventAssociation.eventCode").value("ALM-WS_ENV_HIGH-001"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.workOrders[1].eventAssociation.eventCode").value("ALM-WS_ENV_PH-002"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void timelinesAreIndependentBetweenWorkOrders() throws Exception {
|
|
|
|
|
+ when(adapter.loadTimeline("WO-2026090200001")).thenReturn(timelineFixture("WO-2026090200001", "3"));
|
|
|
|
|
+ when(adapter.loadTimeline("WO-2026090200002")).thenReturn(timelineFixture("WO-2026090200002", "6"));
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/work-orders/WO-2026090200001/timeline")
|
|
|
|
|
+ .header("X-Request-Id", "timeline-001"))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.data.requestId").value("timeline-001"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.workOrderCode").value("WO-2026090200001"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.stateDictionary", hasSize(9)))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.timelineEntries", hasSize(3)))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.timelineEntries[0].workOrderCode").value("WO-2026090200001"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.timelineEntries[0].legacyState").value(1))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.timelineEntries[0].authoritativeState").value(1))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.timelineEntries[2].legacyState").value(3))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.timelineEntries[2].authoritativeState").value(3));
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/work-orders/WO-2026090200002/timeline")
|
|
|
|
|
+ .header("X-Request-Id", "timeline-002"))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.data.workOrderCode").value("WO-2026090200002"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.timelineEntries", hasSize(1)))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.timelineEntries[0].workOrderCode").value("WO-2026090200002"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.timelineEntries[0].legacyState").value(6))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.timelineEntries[0].authoritativeState").value(6));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void emptyDeviceWorkOrdersReturnSuccessfulEmptyState() throws Exception {
|
|
|
|
|
+ when(adapter.loadByDevice(DEVICE_CODE)).thenReturn(new CockpitWorkOrderReadSnapshot(
|
|
|
|
|
+ DEVICE_CODE, null, null, Collections.emptyList()));
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/work-orders"))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.data.state").value("EMPTY"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.stateReason").value("NO_WORK_ORDERS"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.workOrders", hasSize(0)));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void missingDeviceAndAdapterFailureUseTypedReadOnlyErrors() throws Exception {
|
|
|
|
|
+ when(adapter.loadByDevice("MISSING")).thenThrow(new CockpitDeviceNotFoundException("device not found"));
|
|
|
|
|
+ when(adapter.loadByDevice("BROKEN")).thenThrow(new CockpitReadException("read failed"));
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/equipments/MISSING/work-orders"))
|
|
|
|
|
+ .andExpect(status().isNotFound())
|
|
|
|
|
+ .andExpect(jsonPath("$.data.category").value("NOT_FOUND"));
|
|
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/equipments/BROKEN/work-orders"))
|
|
|
|
|
+ .andExpect(status().isInternalServerError())
|
|
|
|
|
+ .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void controllerExposesReadOnlyMappedMethodsOnly() throws Exception {
|
|
|
|
|
+ Method[] methods = CockpitWorkOrderReadController.class.getDeclaredMethods();
|
|
|
|
|
+ for (Method method : methods) {
|
|
|
|
|
+ if (method.isAnnotationPresent(org.springframework.web.bind.annotation.GetMapping.class)) {
|
|
|
|
|
+ assertTrue(!method.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)
|
|
|
|
|
+ && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PutMapping.class)
|
|
|
|
|
+ && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PatchMapping.class)
|
|
|
|
|
+ && !method.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(post("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/work-orders"))
|
|
|
|
|
+ .andExpect(status().isMethodNotAllowed());
|
|
|
|
|
+ verifyNoInteractions(adapter);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void assertTrue(boolean condition) {
|
|
|
|
|
+ org.junit.jupiter.api.Assertions.assertTrue(condition);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private CockpitWorkOrderReadSnapshot nineStateFixture() {
|
|
|
|
|
+ List<CockpitWorkOrderSummary> summaries = new java.util.ArrayList<>();
|
|
|
|
|
+ for (int state = 1; state <= 9; state++) {
|
|
|
|
|
+ summaries.add(summary("WO-202609020000" + state, state, "ALM-20260902-00" + state));
|
|
|
|
|
+ }
|
|
|
|
|
+ return new CockpitWorkOrderReadSnapshot(DEVICE_CODE, null, null, summaries);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private CockpitWorkOrderReadSnapshot singleAlarmFixture() {
|
|
|
|
|
+ return new CockpitWorkOrderReadSnapshot(
|
|
|
|
|
+ DEVICE_CODE,
|
|
|
|
|
+ "ALARM",
|
|
|
|
|
+ "ALM-20260902-001",
|
|
|
|
|
+ Collections.singletonList(summary("WO-2026090200001", 1, "ALM-20260902-001")));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private CockpitWorkOrderReadSnapshot singleWarningFixture() {
|
|
|
|
|
+ return new CockpitWorkOrderReadSnapshot(
|
|
|
|
|
+ DEVICE_CODE,
|
|
|
|
|
+ "WARNING",
|
|
|
|
|
+ "WRN-20260902-001",
|
|
|
|
|
+ Collections.singletonList(new CockpitWorkOrderSummary(
|
|
|
|
|
+ "WO-2026090200002", DEVICE_CODE, 1, "故障维修", 2, "处理异常", 2, "待接单", 2, "待接单", false,
|
|
|
|
|
+ "2026-09-02T09:59:30+08:00", "2026-09-02T10:00:00+08:00", "2026-09-02T09:00:00+08:00", "2026-09-02T09:30:00+08:00",
|
|
|
|
|
+ new CockpitWorkOrderEventAssociation("WARNING", "WRN-20260902-001", "2026-09-02T10:00:00+08:00"))));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private CockpitWorkOrderSummary summary(String workOrderCode, int legacyState, String eventCode) {
|
|
|
|
|
+ CockpitWorkOrderState state = CockpitWorkOrderStateDictionary.byLegacyCode(legacyState);
|
|
|
|
|
+ return new CockpitWorkOrderSummary(
|
|
|
|
|
+ workOrderCode,
|
|
|
|
|
+ DEVICE_CODE,
|
|
|
|
|
+ 1,
|
|
|
|
|
+ "故障维修",
|
|
|
|
|
+ 2,
|
|
|
|
|
+ "处理异常",
|
|
|
|
|
+ state.getLegacyCode(),
|
|
|
|
|
+ state.getLegacyName(),
|
|
|
|
|
+ state.getAuthoritativeCode(),
|
|
|
|
|
+ state.getAuthoritativeName(),
|
|
|
|
|
+ state.isDeprecated(),
|
|
|
|
|
+ "2026-09-02T09:59:30+08:00",
|
|
|
|
|
+ "2026-09-02T10:00:00+08:00",
|
|
|
|
|
+ "2026-09-02T09:00:00+08:00",
|
|
|
|
|
+ "2026-09-02T09:30:00+08:00",
|
|
|
|
|
+ new CockpitWorkOrderEventAssociation("ALARM", eventCode, "2026-09-02T10:00:00+08:00"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private CockpitWorkOrderTimelineSnapshot timelineFixture(String workOrderCode, String lastState) {
|
|
|
|
|
+ int state = Integer.parseInt(lastState);
|
|
|
|
|
+ CockpitWorkOrderSummary summary = summary(workOrderCode, state, "ALM-20260902-001");
|
|
|
|
|
+ if (state >= 6) {
|
|
|
|
|
+ return new CockpitWorkOrderTimelineSnapshot(
|
|
|
|
|
+ summary,
|
|
|
|
|
+ Collections.singletonList(entry(workOrderCode, 6, 6)));
|
|
|
|
|
+ }
|
|
|
|
|
+ return new CockpitWorkOrderTimelineSnapshot(
|
|
|
|
|
+ summary,
|
|
|
|
|
+ Arrays.asList(
|
|
|
|
|
+ entry(workOrderCode, 1, 1),
|
|
|
|
|
+ entry(workOrderCode, 2, 2),
|
|
|
|
|
+ entry(workOrderCode, 3, 3)));
|
|
|
|
|
+ }
|
|
|
|
|
+ private CockpitWorkOrderTimelineEntry entry(String workOrderCode, int legacyState, int authoritativeState) {
|
|
|
|
|
+ return new CockpitWorkOrderTimelineEntry(
|
|
|
|
|
+ (long) legacyState,
|
|
|
|
|
+ workOrderCode,
|
|
|
|
|
+ legacyState,
|
|
|
|
|
+ CockpitWorkOrderStateDictionary.byLegacyCode(legacyState).getLegacyName(),
|
|
|
|
|
+ authoritativeState,
|
|
|
|
|
+ CockpitWorkOrderStateDictionary.byLegacyCode(legacyState).getAuthoritativeName(),
|
|
|
|
|
+ false,
|
|
|
|
|
+ "创建",
|
|
|
|
|
+ "创建工单",
|
|
|
|
|
+ "系统",
|
|
|
|
|
+ "2026-09-02T09:00:00+08:00");
|
|
|
|
|
+ }
|
|
|
|
|
+}
|