Bladeren bron

fix(cockpit): unify work order associations across read endpoints

Kazerin 1 week geleden
bovenliggende
commit
894402a6d4

+ 4 - 0
pipe-network-service/CONTEXT.md

@@ -202,6 +202,10 @@ _Avoid_:把工单主键、设备主键或事件编号当作设备业务编码
 报警记录进入处置流程后指向关联工单;工单同时指向设备。预警与报警仍是不同事件家族,预警需先通过其关联的报警才能推导工单,不能用事件名称或时间猜测。
 _Avoid_:从工单标题、报警时间或旧的工单设备编码字段反推关系
 
+**驾驶舱工单关联**:
+驾驶舱按报警与工单的一对一关系呈现关联事实;多个报警引用同一工单属于关联数据异常,不能任意选取一条充当主报警。未关联报警的工单仍是有效工单,须明确呈现未关联。
+_Avoid_:从多个关联报警中默认取首条、用缺少事件查询语境推断未关联
+
 **正式行业**:
 驾驶舱筛选与统计使用供水、排水、燃气、井盖四类;井盖在当前设备类型树中是独立根类型,不应静默归入排水。
 _Avoid_:把井盖当作排水子类或非正式行业

+ 2 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitWorkOrderReadAdapterTest.java

@@ -86,6 +86,8 @@ class BusinessCockpitWorkOrderReadAdapterTest {
     void alarmEventResolvesByAlarmIdAndReturnsItsLinkedOrders() {
         AlarmData alarm = alarm(ALARM_ID, ALARM_NO, 279L);
         when(alarmDataService.getById(ALARM_ID)).thenReturn(alarm);
+        when(alarmDataService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(alarm));
         when(workOrderService.list(any(LambdaQueryWrapper.class)))
                 .thenReturn(Collections.singletonList(order(279L, "WO-279", 5)));
 

+ 243 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitWorkOrderAssociationContractTest.java

@@ -0,0 +1,243 @@
+package com.zksy.web.controller.cockpit;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper;
+import com.zksy.base.alarm.domain.AlarmData;
+import com.zksy.base.alarm.service.AlarmDataService;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.mapper.EquipmentBaseMapper;
+import com.zksy.base.warning.mapper.AlarmWarningRelMapper;
+import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.cockpit.workorder.BusinessCockpitWorkOrderReadAdapter;
+import com.zksy.cockpit.workorder.CockpitWorkOrderReadService;
+import com.zksy.ops.domain.WorkOrder;
+import com.zksy.ops.service.WorkOrderService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.apache.ibatis.exceptions.PersistenceException;
+import org.springframework.dao.DataAccessResourceFailureException;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultActions;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.nullValue;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/** 真实适配器贯穿三个 HTTP 入口,锁定执行票 21 的关联口径。 */
+class CockpitWorkOrderAssociationContractTest {
+    private static final String DEVICE_CODE = "21119740";
+    private static final String EQUIPMENT_ID = "equipment-id";
+    private static final String ORDER_CODE = "WO202610101511303516";
+    private static final String ALARM_ID = "cd57be42bd18d02f7d3390fb98539b93";
+    private static final String ALARM_NO = "BJ202610101511305660";
+    private static final String ASSOCIATED_AT = "2026-10-10T15:11:30+08:00";
+    private static final String BASE = "/api/cockpit/v1";
+
+    private WorkOrderService workOrderService;
+    private AlarmDataService alarmDataService;
+    private LambdaQueryChainWrapper<WorkOrder> orderQuery;
+    private MockMvc mockMvc;
+    private WorkOrder linkedOrder;
+    private AlarmData linkedAlarm;
+
+    @BeforeEach
+    @SuppressWarnings("unchecked")
+    void setUp() {
+        for (Class<?> entity : Arrays.asList(EquipmentBase.class, WorkOrder.class, AlarmData.class)) {
+            TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), ""), entity);
+        }
+        workOrderService = mock(WorkOrderService.class);
+        alarmDataService = mock(AlarmDataService.class);
+        EquipmentBaseMapper equipmentMapper = mock(EquipmentBaseMapper.class);
+        EquipmentBase equipment = new EquipmentBase();
+        equipment.setEquipmentId(EQUIPMENT_ID);
+        equipment.setEquipmentCode(DEVICE_CODE);
+        when(equipmentMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(equipment);
+        when(equipmentMapper.selectById(EQUIPMENT_ID)).thenReturn(equipment);
+
+        linkedOrder = order(279L, ORDER_CODE);
+        linkedAlarm = new AlarmData();
+        linkedAlarm.setId(ALARM_ID);
+        linkedAlarm.setAlarmNo(ALARM_NO);
+        linkedAlarm.setWorkOrderId(279L);
+        when(workOrderService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(linkedOrder));
+        when(alarmDataService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(linkedAlarm));
+        when(alarmDataService.getById(ALARM_ID)).thenReturn(linkedAlarm);
+        when(workOrderService.listLogs(279L)).thenReturn(Collections.emptyList());
+        orderQuery = mock(LambdaQueryChainWrapper.class);
+        when(workOrderService.lambdaQuery()).thenReturn(orderQuery);
+        when(orderQuery.eq(any(), any())).thenReturn(orderQuery);
+        when(orderQuery.one()).thenReturn(linkedOrder);
+
+        BusinessCockpitWorkOrderReadAdapter adapter = new BusinessCockpitWorkOrderReadAdapter(
+                workOrderService, mock(EarlyWarningService.class), mock(AlarmWarningRelMapper.class),
+                alarmDataService, equipmentMapper);
+        CockpitWorkOrderReadService service = new CockpitWorkOrderReadService(
+                adapter, Clock.fixed(Instant.parse("2026-10-10T07:11:30Z"), ZoneOffset.UTC));
+        mockMvc = MockMvcBuilders.standaloneSetup(new CockpitWorkOrderReadController(service)).build();
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    void sameOrderHasIdenticalAssociationAcrossDeviceAlarmAndTimeline() throws Exception {
+        expectLinked(mockMvc.perform(get(BASE + "/equipments/" + DEVICE_CODE + "/work-orders")),
+                "$.data.workOrders[0]")
+                .andExpect(jsonPath("$.data.unassociatedWorkOrderCount").value(0));
+        expectLinked(mockMvc.perform(get(BASE + "/events/ALARM/" + ALARM_ID + "/work-orders")),
+                "$.data.workOrders[0]")
+                .andExpect(jsonPath("$.data.eventCode").value(ALARM_ID))
+                .andExpect(jsonPath("$.data.unassociatedWorkOrderCount").value(0));
+        expectLinked(mockMvc.perform(get(BASE + "/work-orders/" + ORDER_CODE + "/timeline")),
+                "$.data.workOrder")
+                .andExpect(jsonPath("$.data.timelineEntries", hasSize(0)));
+
+        // 三个入口均反查真实工单主键,不能靠设备、工单号或时间猜测。
+        ArgumentCaptor<LambdaQueryWrapper<AlarmData>> queries = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(alarmDataService, times(3)).list(queries.capture());
+        for (LambdaQueryWrapper<AlarmData> query : queries.getAllValues()) {
+            assertTrue(query.getSqlSegment().contains("work_order_id IN"));
+            assertEquals(Collections.singletonList(279L),
+                    new java.util.ArrayList<>(query.getParamNameValuePairs().values()));
+        }
+    }
+
+    @Test
+    void alarmNumberAliasReturnsTheSameAssociationAsDeviceAndTimeline() throws Exception {
+        expectLinked(mockMvc.perform(get(BASE + "/events/ALARM/" + ALARM_NO + "/work-orders")),
+                "$.data.workOrders[0]")
+                .andExpect(jsonPath("$.data.eventCode").value(ALARM_ID));
+        expectLinked(mockMvc.perform(get(BASE + "/equipments/" + DEVICE_CODE + "/work-orders")),
+                "$.data.workOrders[0]");
+        expectLinked(mockMvc.perform(get(BASE + "/work-orders/" + ORDER_CODE + "/timeline")),
+                "$.data.workOrder");
+        ArgumentCaptor<LambdaQueryWrapper<AlarmData>> queries = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(alarmDataService, times(4)).list(queries.capture());
+        LambdaQueryWrapper<AlarmData> aliasQuery = queries.getAllValues().get(0);
+        assertTrue(aliasQuery.getSqlSegment().contains("alarm_no ="));
+        assertEquals(Collections.singletonList(ALARM_NO),
+                new java.util.ArrayList<>(aliasQuery.getParamNameValuePairs().values()));
+    }
+
+    @Test
+    void unlinkedOrderIsExplicitlyFalseInDeviceAndTimelineAndAbsentFromAlarm() throws Exception {
+        when(alarmDataService.list(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+        linkedAlarm.setWorkOrderId(null);
+        expectUnlinked(mockMvc.perform(get(BASE + "/equipments/" + DEVICE_CODE + "/work-orders")),
+                "$.data.workOrders[0]")
+                .andExpect(jsonPath("$.data.unassociatedWorkOrderCount").value(1));
+        expectUnlinked(mockMvc.perform(get(BASE + "/work-orders/" + ORDER_CODE + "/timeline")),
+                "$.data.workOrder");
+        mockMvc.perform(get(BASE + "/events/ALARM/" + ALARM_ID + "/work-orders"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.workOrders", hasSize(0)))
+                .andExpect(jsonPath("$.data.state").value("EMPTY"));
+    }
+
+    @Test
+    void anotherOrderOnSameDeviceDoesNotInheritLinkedOrdersAlarm() throws Exception {
+        WorkOrder orphan = order(281L, "WO-UNLINKED");
+        List<WorkOrder> orders = Arrays.asList(linkedOrder, orphan);
+        when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(orders);
+        expectLinked(mockMvc.perform(get(BASE + "/equipments/" + DEVICE_CODE + "/work-orders")),
+                "$.data.workOrders[0]");
+        expectUnlinked(mockMvc.perform(get(BASE + "/equipments/" + DEVICE_CODE + "/work-orders")),
+                "$.data.workOrders[1]")
+                .andExpect(jsonPath("$.data.unassociatedWorkOrderCount").value(1));
+        when(orderQuery.one()).thenReturn(orphan);
+        expectUnlinked(mockMvc.perform(get(BASE + "/work-orders/WO-UNLINKED/timeline")),
+                "$.data.workOrder");
+    }
+
+    @Test
+    void missingOrderReturnsNotFoundBeforeReadingAssociations() throws Exception {
+        when(orderQuery.one()).thenReturn(null);
+        mockMvc.perform(get(BASE + "/work-orders/MISSING/timeline"))
+                .andExpect(status().isNotFound());
+        verify(alarmDataService, never()).list(any(LambdaQueryWrapper.class));
+    }
+
+    @Test
+    void duplicateAlarmReferencesFailAcrossAllScopesRegardlessOfOrder() throws Exception {
+        AlarmData otherAlarm = new AlarmData();
+        otherAlarm.setId("another-alarm");
+        otherAlarm.setWorkOrderId(279L);
+        when(alarmDataService.getById(otherAlarm.getId())).thenReturn(otherAlarm);
+        for (List<AlarmData> alarms : Arrays.asList(
+                Arrays.asList(linkedAlarm, otherAlarm), Arrays.asList(otherAlarm, linkedAlarm))) {
+            when(alarmDataService.list(any(LambdaQueryWrapper.class))).thenReturn(alarms);
+            expectAssociationFailure(mockMvc.perform(get(BASE + "/equipments/" + DEVICE_CODE + "/work-orders")));
+            expectAssociationFailure(mockMvc.perform(get(BASE + "/work-orders/" + ORDER_CODE + "/timeline")));
+            expectAssociationFailure(mockMvc.perform(get(BASE + "/events/ALARM/" + ALARM_ID + "/work-orders")));
+            expectAssociationFailure(mockMvc.perform(get(BASE + "/events/ALARM/" + otherAlarm.getId() + "/work-orders")));
+        }
+    }
+
+    @Test
+    void databaseAssociationReadFailureDoesNotBecomeSuccessfulUnassociatedOrder() throws Exception {
+        for (RuntimeException failure : Arrays.asList(
+                new DataAccessResourceFailureException("database unavailable"),
+                new PersistenceException("association query failed"))) {
+            org.mockito.Mockito.doThrow(failure).when(alarmDataService).list(any(LambdaQueryWrapper.class));
+            expectAssociationFailure(mockMvc.perform(get(BASE + "/equipments/" + DEVICE_CODE + "/work-orders")));
+            expectAssociationFailure(mockMvc.perform(get(BASE + "/work-orders/" + ORDER_CODE + "/timeline")));
+            expectAssociationFailure(mockMvc.perform(get(BASE + "/events/ALARM/" + ALARM_ID + "/work-orders")));
+        }
+    }
+
+    private static ResultActions expectAssociationFailure(ResultActions result) throws Exception {
+        return result.andExpect(status().isInternalServerError())
+                .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"))
+                .andExpect(jsonPath("$.data.workOrder").doesNotExist())
+                .andExpect(jsonPath("$.data.workOrders").doesNotExist());
+    }
+
+    private static ResultActions expectLinked(ResultActions result, String path) throws Exception {
+        return result.andExpect(status().isOk())
+                .andExpect(jsonPath(path + ".workOrderCode").value(ORDER_CODE))
+                .andExpect(jsonPath(path + ".associatedWithCurrentEvent").value(true))
+                .andExpect(jsonPath(path + ".eventAssociation.eventSource").value("ALARM"))
+                .andExpect(jsonPath(path + ".eventAssociation.eventCode").value(ALARM_ID))
+                .andExpect(jsonPath(path + ".eventAssociation.associatedAt").value(ASSOCIATED_AT));
+    }
+
+    private static ResultActions expectUnlinked(ResultActions result, String path) throws Exception {
+        return result.andExpect(status().isOk())
+                .andExpect(jsonPath(path + ".associatedWithCurrentEvent").value(false))
+                .andExpect(jsonPath(path + ".eventAssociation").value(nullValue()));
+    }
+
+    private static WorkOrder order(Long id, String code) {
+        WorkOrder order = new WorkOrder();
+        order.setOrderId(id);
+        order.setOrderNo(code);
+        order.setDeviceId(EQUIPMENT_ID);
+        order.setOrderStatus(5);
+        order.setCreateTime(Date.from(Instant.parse("2026-10-10T07:11:30Z")));
+        return order;
+    }
+}

+ 33 - 17
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/BusinessCockpitWorkOrderReadAdapter.java

@@ -12,7 +12,10 @@ import com.zksy.base.warning.domain.AlarmWarningRel;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.mapper.AlarmWarningRelMapper;
 import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import org.apache.ibatis.exceptions.PersistenceException;
 import org.springframework.context.annotation.Primary;
+import org.springframework.dao.DataAccessException;
 import org.springframework.stereotype.Component;
 
 import java.time.ZoneId;
@@ -63,16 +66,14 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
         // (alarm_data.work_order_id = work_order.order_id,经真实库核实),
         // 未关联任何报警的工单以 associatedWithCurrentEvent=false 显式标出 ——
         // 前端据此提示「未关联当前报警」,而不是从 null 反推(执行票 12 范围 1)。
+        return new CockpitWorkOrderReadSnapshot(deviceCode, null, null, toAlarmSummaries(orders));
+    }
+
+    private List<CockpitWorkOrderSummary> toAlarmSummaries(List<WorkOrder> orders) {
         Map<Long, AlarmData> alarmByOrderId = alarmsByOrderId(orders);
-        List<CockpitWorkOrderSummary> summaries = orders.stream()
-                .map(order -> {
-                    AlarmData alarm = alarmByOrderId.get(order.getOrderId());
-                    return alarm == null
-                            ? toSummary(order, null, null, false)
-                            : toSummary(order, "ALARM", alarmReference(alarm), true);
-                })
+        return orders.stream()
+                .map(order -> toAlarmSummary(order, alarmByOrderId.get(order.getOrderId())))
                 .collect(Collectors.toList());
-        return new CockpitWorkOrderReadSnapshot(deviceCode, null, null, summaries);
     }
 
     /**
@@ -142,12 +143,20 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
         if (orderIds.isEmpty()) {
             return Collections.emptyMap();
         }
+        List<AlarmData> alarms;
+        try {
+            alarms = alarmDataService.list(new LambdaQueryWrapper<AlarmData>()
+                    .in(AlarmData::getWorkOrderId, orderIds));
+        } catch (DataAccessException | PersistenceException exception) {
+            throw new CockpitReadException("work order association read failed");
+        }
         Map<Long, AlarmData> result = new LinkedHashMap<>();
-        for (AlarmData alarm : alarmDataService.list(new LambdaQueryWrapper<AlarmData>()
-                .in(AlarmData::getWorkOrderId, orderIds))) {
+        for (AlarmData alarm : alarms) {
             if (alarm != null && alarm.getWorkOrderId() != null) {
-                // 同一工单若被多条报警引用,取最先返回的一条即可(不臆造「主报警」规则)。
-                result.putIfAbsent(alarm.getWorkOrderId(), alarm);
+                // 工单与报警按一对一消费;多条引用是数据异常,不静默选取主报警。
+                if (result.putIfAbsent(alarm.getWorkOrderId(), alarm) != null) {
+                    throw new CockpitReadException("multiple alarms reference the same work order");
+                }
             }
         }
         return result;
@@ -167,9 +176,7 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
             // 回显的 eventCode 用报警真实单号(与请求标识一致的可读值)。
             String resolvedEventCode = alarm == null ? eventCode : alarmReference(alarm);
             return new CockpitWorkOrderReadSnapshot(null, eventSource, resolvedEventCode,
-                    orders.stream()
-                            .map(order -> toSummary(order, eventSource, resolvedEventCode, true))
-                            .collect(Collectors.toList()));
+                    toAlarmSummaries(orders));
         }
         if ("WARNING".equals(eventSource)) {
             EarlyWarning warning = earlyWarningService.lambdaQuery()
@@ -209,8 +216,17 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
         List<CockpitWorkOrderTimelineEntry> entries = logs.stream()
                 .map(log -> toTimelineEntry(order, log))
                 .collect(Collectors.toList());
-        // 时间线单张工单:无「当前报警」语境,关联标记同设备维度。
-        return new CockpitWorkOrderTimelineSnapshot(toSummary(order, null, null, false), entries);
+        // 时间线与设备维度按同一真实关联键解析,不因缺少事件查询参数而标为未关联。
+        AlarmData alarm = alarmsByOrderId(Collections.singletonList(order)).get(order.getOrderId());
+        return new CockpitWorkOrderTimelineSnapshot(toAlarmSummary(order, alarm), entries);
+    }
+
+    private CockpitWorkOrderSummary toAlarmSummary(WorkOrder order, AlarmData alarm) {
+        boolean associated = alarm != null && order.getOrderId() != null
+                && order.getOrderId().equals(alarm.getWorkOrderId());
+        return associated
+                ? toSummary(order, "ALARM", alarmReference(alarm), true)
+                : toSummary(order, null, null, false);
     }
 
     private CockpitWorkOrderSummary toSummary(