5e492e5d5a2b743e2e99443220d343f72a633f6d..53332ecb0edc651bae91f1e7a74a795d76d87cb2
1 天以前 Junjie
#
53332e 对比 | 目录
1 天以前 Junjie
#
dfa40c 对比 | 目录
1 天以前 Junjie
#
2bba23 对比 | 目录
1 天以前 Junjie
#
0b1cc5 对比 | 目录
1 天以前 Junjie
#
881029 对比 | 目录
1 天以前 Junjie
#
af31b8 对比 | 目录
8个文件已添加
12个文件已修改
1752 ■■■■■ 已修改文件
src/main/java/com/zy/asrs/controller/ConsoleController.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/domain/vo/StationCycleCapacityVo.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/domain/vo/StationCycleLoopVo.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/service/StationCycleCapacityService.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/service/impl/StationCycleCapacityServiceImpl.java 623 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/task/StationCycleCapacityScheduler.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/ws/ConsoleWebSocket.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/ServerBootstrap.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/enums/RedisKeyType.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/model/command/StationCommand.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/network/ZyStationConnectDriver.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/network/entity/ZyStationStatusEntity.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/network/fake/ZyStationV4FakeSegConnect.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/network/real/ZyStationV4RealConnect.java 335 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/thread/impl/ZyStationV4Thread.java 143 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/utils/CrnOperateProcessUtils.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/utils/StationOperateProcessUtils.java 349 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application.yml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/仿真测试备份_1.0.4.6.nb3 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/MapCanvas.js 158 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/controller/ConsoleController.java
@@ -59,6 +59,8 @@
    private LocMastService locMastService;
    @Autowired
    private BasMapService basMapService;
    @Autowired
    private StationCycleCapacityService stationCycleCapacityService;
    @PostMapping("/system/running/status")
    @ManagerAuth(memo = "系统运行状态")
@@ -266,6 +268,11 @@
        return R.ok().add(vos);
    }
    @GetMapping("/latest/data/station/cycle/capacity")
    public R stationCycleCapacity() {
        return R.ok().add(stationCycleCapacityService.getLatestSnapshot());
    }
    // @PostMapping("/latest/data/barcode")
    // @ManagerAuth(memo = "条码扫描仪实时数据")
    // public R barcodeLatestData(){
src/main/java/com/zy/asrs/domain/vo/StationCycleCapacityVo.java
New file
@@ -0,0 +1,29 @@
package com.zy.asrs.domain.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
@Data
public class StationCycleCapacityVo {
    // å¾ªçŽ¯åœˆæ˜Žç»†
    private List<StationCycleLoopVo> loopList = new ArrayList<>();
    // å¾ªçŽ¯åœˆæ•°é‡
    private Integer loopCount = 0;
    // å¾ªçŽ¯åœˆç«™ç‚¹æ€»æ•°
    private Integer totalStationCount = 0;
    // å¾ªçŽ¯åœˆä¸­æœ‰ä»»åŠ¡ç«™ç‚¹æ€»æ•°
    private Integer taskStationCount = 0;
    // å½“前承载量(0-1):当前任务数 / å¾ªçŽ¯åœˆæ€»ç«™ç‚¹æ•°
    private Double currentLoad = 0.0;
    // æœ€æ–°åˆ·æ–°æ—¶é—´
    private Date refreshTime;
}
src/main/java/com/zy/asrs/domain/vo/StationCycleLoopVo.java
New file
@@ -0,0 +1,28 @@
package com.zy.asrs.domain.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class StationCycleLoopVo {
    // å¾ªçŽ¯åœˆåºå·ï¼ˆä»Ž1开始)
    private Integer loopNo;
    // å¾ªçŽ¯åœˆå†…ç«™ç‚¹ç¼–å·
    private List<Integer> stationIdList = new ArrayList<>();
    // å¾ªçŽ¯åœˆå†…å­˜åœ¨çš„å·¥ä½œå·
    private List<Integer> workNoList = new ArrayList<>();
    // å¾ªçŽ¯åœˆç«™ç‚¹æ€»æ•°
    private Integer stationCount = 0;
    // å¾ªçŽ¯åœˆå†…æœ‰ä»»åŠ¡ç«™ç‚¹æ•°
    private Integer taskCount = 0;
    // å½“前承载量(0-1):当前任务数 / å½“前循环圈总站点数
    private Double currentLoad = 0.0;
}
src/main/java/com/zy/asrs/service/StationCycleCapacityService.java
New file
@@ -0,0 +1,13 @@
package com.zy.asrs.service;
import com.zy.asrs.domain.vo.StationCycleCapacityVo;
public interface StationCycleCapacityService {
    // ç«‹å³åˆ·æ–°å¾ªçŽ¯åœˆä¸Žæ‰¿è½½é‡å¿«ç…§
    void refreshSnapshot();
    // èŽ·å–æœ€æ–°å¾ªçŽ¯åœˆä¸Žæ‰¿è½½é‡å¿«ç…§
    StationCycleCapacityVo getLatestSnapshot();
}
src/main/java/com/zy/asrs/service/impl/StationCycleCapacityServiceImpl.java
New file
@@ -0,0 +1,623 @@
package com.zy.asrs.service.impl;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.mapper.EntityWrapper;
import com.zy.asrs.domain.vo.StationCycleCapacityVo;
import com.zy.asrs.domain.vo.StationCycleLoopVo;
import com.zy.asrs.entity.BasDevp;
import com.zy.asrs.entity.DeviceConfig;
import com.zy.asrs.service.BasMapService;
import com.zy.asrs.service.BasDevpService;
import com.zy.asrs.service.DeviceConfigService;
import com.zy.asrs.service.StationCycleCapacityService;
import com.zy.common.model.NavigateNode;
import com.zy.common.utils.RedisUtil;
import com.zy.common.utils.NavigateSolution;
import com.zy.core.cache.SlaveConnection;
import com.zy.core.enums.RedisKeyType;
import com.zy.core.enums.SlaveType;
import com.zy.core.model.StationObjModel;
import com.zy.core.model.protocol.StationProtocol;
import com.zy.core.thread.StationThread;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
@Service("stationCycleCapacityService")
@Slf4j
public class StationCycleCapacityServiceImpl implements StationCycleCapacityService {
    private static final long LOOP_LOAD_RESERVE_EXPIRE_MILLIS = 120_000L;
    @Autowired
    private BasMapService basMapService;
    @Autowired
    private DeviceConfigService deviceConfigService;
    @Autowired
    private BasDevpService basDevpService;
    @Autowired
    private RedisUtil redisUtil;
    private final AtomicReference<StationCycleCapacityVo> snapshotRef = new AtomicReference<>(new StationCycleCapacityVo());
    @Override
    public synchronized void refreshSnapshot() {
        try {
            StationCycleCapacityVo snapshot = buildSnapshot();
            snapshotRef.set(snapshot);
        } catch (Exception e) {
            log.error("刷新循环圈承载量失败", e);
        }
    }
    @Override
    public StationCycleCapacityVo getLatestSnapshot() {
        StationCycleCapacityVo snapshot = snapshotRef.get();
        if (snapshot == null || snapshot.getRefreshTime() == null) {
            refreshSnapshot();
            snapshot = snapshotRef.get();
        }
        return snapshot == null ? new StationCycleCapacityVo() : snapshot;
    }
    private StationCycleCapacityVo buildSnapshot() {
        GraphContext context = buildStationGraph();
        Map<Integer, Integer> workNoMap = buildStationWorkNoMap();
        Set<Integer> availableStationSet = new HashSet<>(context.graph.keySet());
        availableStationSet.removeAll(context.excludeStationSet);
        Map<Integer, Set<Integer>> filteredGraph = new HashMap<>();
        for (Integer stationId : availableStationSet) {
            Set<Integer> nextSet = context.graph.getOrDefault(stationId, Collections.emptySet());
            Set<Integer> filteredNext = new HashSet<>();
            for (Integer nextId : nextSet) {
                if (availableStationSet.contains(nextId)) {
                    filteredNext.add(nextId);
                }
            }
            filteredGraph.put(stationId, filteredNext);
        }
        List<Set<Integer>> sccList = findStrongConnectedComponents(filteredGraph);
        List<StationCycleLoopVo> loopList = new ArrayList<>();
        int loopNo = 1;
        int totalStationCount = 0;
        int taskStationCount = 0;
        Set<Integer> actualWorkNoSet = new HashSet<>();
        for (Set<Integer> scc : sccList) {
            if (!isCycleScc(scc, filteredGraph)) {
                continue;
            }
            // å¯¹ SCC å†åšä¸€æ¬¡â€œçŽ¯æ ¸å¿ƒâ€å‰¥ç¦»ï¼Œå‰”é™¤æžæˆ/死胡同节点
            List<Set<Integer>> coreLoopList = extractCoreLoopComponents(scc, filteredGraph);
            for (Set<Integer> coreLoop : coreLoopList) {
                List<Integer> stationIdList = new ArrayList<>(coreLoop);
                Collections.sort(stationIdList);
                List<Integer> workNoList = new ArrayList<>();
                int currentLoopTaskCount = 0;
                for (Integer stationId : stationIdList) {
                    Integer workNo = workNoMap.get(stationId);
                    if (workNo != null && workNo > 0) {
                        workNoList.add(workNo);
                        currentLoopTaskCount++;
                        actualWorkNoSet.add(workNo);
                    }
                }
                StationCycleLoopVo loopVo = new StationCycleLoopVo();
                loopVo.setLoopNo(loopNo++);
                loopVo.setStationIdList(stationIdList);
                loopVo.setWorkNoList(workNoList);
                loopVo.setStationCount(stationIdList.size());
                loopVo.setTaskCount(currentLoopTaskCount);
                loopVo.setCurrentLoad(calcCurrentLoad(currentLoopTaskCount, stationIdList.size()));
                loopList.add(loopVo);
                totalStationCount += stationIdList.size();
                taskStationCount += currentLoopTaskCount;
            }
        }
        int reserveTaskCount = mergeReserveTaskCount(loopList, actualWorkNoSet);
        taskStationCount += reserveTaskCount;
        StationCycleCapacityVo vo = new StationCycleCapacityVo();
        vo.setLoopList(loopList);
        vo.setLoopCount(loopList.size());
        vo.setTotalStationCount(totalStationCount);
        vo.setTaskStationCount(taskStationCount);
        vo.setCurrentLoad(calcCurrentLoad(taskStationCount, totalStationCount));
        vo.setRefreshTime(new Date());
        return vo;
    }
    private int mergeReserveTaskCount(List<StationCycleLoopVo> loopList, Set<Integer> actualWorkNoSet) {
        if (loopList == null || loopList.isEmpty()) {
            return 0;
        }
        Map<Object, Object> reserveMap = redisUtil.hmget(RedisKeyType.STATION_CYCLE_LOAD_RESERVE.key);
        if (reserveMap == null || reserveMap.isEmpty()) {
            return 0;
        }
        Map<Integer, StationCycleLoopVo> loopMap = new HashMap<>();
        Map<Integer, StationCycleLoopVo> stationLoopMap = new HashMap<>();
        for (StationCycleLoopVo loopVo : loopList) {
            if (loopVo != null && loopVo.getLoopNo() != null) {
                loopMap.put(loopVo.getLoopNo(), loopVo);
            }
            if (loopVo == null || loopVo.getStationIdList() == null) {
                continue;
            }
            for (Integer stationId : loopVo.getStationIdList()) {
                if (stationId != null) {
                    stationLoopMap.put(stationId, loopVo);
                }
            }
        }
        long now = System.currentTimeMillis();
        int mergedCount = 0;
        List<Object> removeFieldList = new ArrayList<>();
        for (Map.Entry<Object, Object> entry : reserveMap.entrySet()) {
            ReserveRecord record = parseReserveRecord(entry.getKey(), entry.getValue());
            if (record == null) {
                removeFieldList.add(entry.getKey());
                continue;
            }
            if (actualWorkNoSet.contains(record.wrkNo)) {
                removeFieldList.add(entry.getKey());
                continue;
            }
            if (record.createTime <= 0 || now - record.createTime > LOOP_LOAD_RESERVE_EXPIRE_MILLIS) {
                removeFieldList.add(entry.getKey());
                continue;
            }
            StationCycleLoopVo loopVo = loopMap.get(record.loopNo);
            if (loopVo == null && record.hitStationId != null) {
                loopVo = stationLoopMap.get(record.hitStationId);
            }
            if (loopVo == null) {
                removeFieldList.add(entry.getKey());
                continue;
            }
            List<Integer> workNoList = loopVo.getWorkNoList();
            if (workNoList == null) {
                workNoList = new ArrayList<>();
                loopVo.setWorkNoList(workNoList);
            }
            if (workNoList.contains(record.wrkNo)) {
                continue;
            }
            workNoList.add(record.wrkNo);
            Collections.sort(workNoList);
            int mergedTaskCount = toNonNegative(loopVo.getTaskCount()) + 1;
            loopVo.setTaskCount(mergedTaskCount);
            loopVo.setCurrentLoad(calcCurrentLoad(mergedTaskCount, toNonNegative(loopVo.getStationCount())));
            mergedCount++;
        }
        if (!removeFieldList.isEmpty()) {
            redisUtil.hdel(RedisKeyType.STATION_CYCLE_LOAD_RESERVE.key, removeFieldList.toArray());
        }
        return mergedCount;
    }
    private ReserveRecord parseReserveRecord(Object fieldObj, Object valueObj) {
        if (fieldObj == null || valueObj == null) {
            return null;
        }
        Integer fieldWrkNo = parseInteger(String.valueOf(fieldObj));
        if (fieldWrkNo == null || fieldWrkNo <= 0) {
            return null;
        }
        JSONObject jsonObject;
        try {
            jsonObject = JSON.parseObject(String.valueOf(valueObj));
        } catch (Exception e) {
            return null;
        }
        if (jsonObject == null) {
            return null;
        }
        Integer wrkNo = jsonObject.getInteger("wrkNo");
        Integer loopNo = jsonObject.getInteger("loopNo");
        Integer hitStationId = jsonObject.getInteger("hitStationId");
        Long createTime = jsonObject.getLong("createTime");
        if (wrkNo == null || wrkNo <= 0) {
            wrkNo = fieldWrkNo;
        }
        if ((loopNo == null || loopNo <= 0) && (hitStationId == null || hitStationId <= 0)) {
            return null;
        }
        if (createTime == null || createTime <= 0) {
            return null;
        }
        ReserveRecord record = new ReserveRecord();
        record.wrkNo = wrkNo;
        record.loopNo = loopNo;
        record.hitStationId = hitStationId;
        record.createTime = createTime;
        return record;
    }
    private Integer parseInteger(String value) {
        if (value == null || value.trim().isEmpty()) {
            return null;
        }
        try {
            return Integer.parseInt(value.trim());
        } catch (Exception e) {
            return null;
        }
    }
    private int toNonNegative(Integer value) {
        if (value == null || value < 0) {
            return 0;
        }
        return value;
    }
    private static class ReserveRecord {
        private Integer wrkNo;
        private Integer loopNo;
        private Integer hitStationId;
        private Long createTime;
    }
    private double calcCurrentLoad(int taskCount, int stationCount) {
        if (stationCount <= 0 || taskCount <= 0) {
            return 0.0;
        }
        double value = (double) taskCount / (double) stationCount;
        if (value < 0.0) {
            return 0.0;
        }
        if (value > 1.0) {
            return 1.0;
        }
        return value;
    }
    private GraphContext buildStationGraph() {
        GraphContext context = new GraphContext();
        List<Integer> levList = basMapService.getLevList();
        if (levList == null || levList.isEmpty()) {
            return context;
        }
        NavigateSolution navigateSolution = new NavigateSolution();
        List<Integer> sortedLevList = new ArrayList<>(levList);
        sortedLevList = new ArrayList<>(new HashSet<>(sortedLevList));
        Collections.sort(sortedLevList);
        for (Integer lev : sortedLevList) {
            List<List<NavigateNode>> stationMap;
            try {
                stationMap = navigateSolution.getStationMap(lev);
            } catch (Exception e) {
                log.warn("加载楼层{}地图失败,跳过循环圈计算", lev);
                continue;
            }
            if (stationMap == null || stationMap.isEmpty()) {
                continue;
            }
            for (List<NavigateNode> row : stationMap) {
                for (NavigateNode node : row) {
                    JSONObject valueObj = parseNodeValue(node.getNodeValue());
                    if (valueObj == null) {
                        continue;
                    }
                    Integer stationId = valueObj.getInteger("stationId");
                    if (stationId == null) {
                        continue;
                    }
                    context.graph.computeIfAbsent(stationId, k -> new HashSet<>());
                    if (isExcludeStation(valueObj)) {
                        context.excludeStationSet.add(stationId);
                    }
                    List<NavigateNode> nextNodeList = navigateSolution.extend_current_node(stationMap, node);
                    if (nextNodeList == null || nextNodeList.isEmpty()) {
                        continue;
                    }
                    for (NavigateNode nextNode : nextNodeList) {
                        JSONObject nextValueObj = parseNodeValue(nextNode.getNodeValue());
                        if (nextValueObj == null) {
                            continue;
                        }
                        Integer nextStationId = nextValueObj.getInteger("stationId");
                        if (nextStationId == null || stationId.equals(nextStationId)) {
                            continue;
                        }
                        context.graph.computeIfAbsent(nextStationId, k -> new HashSet<>());
                        context.graph.get(stationId).add(nextStationId);
                    }
                }
            }
        }
        appendExcludeStationsFromDeviceConfig(context.excludeStationSet);
        return context;
    }
    private void appendExcludeStationsFromDeviceConfig(Set<Integer> excludeStationSet) {
        List<BasDevp> basDevpList = basDevpService.selectList(new EntityWrapper<>());
        if (basDevpList == null || basDevpList.isEmpty()) {
            return;
        }
        for (BasDevp basDevp : basDevpList) {
            List<StationObjModel> inStationList = basDevp.getInStationList$();
            for (StationObjModel stationObjModel : inStationList) {
                if (stationObjModel != null && stationObjModel.getStationId() != null) {
                    excludeStationSet.add(stationObjModel.getStationId());
                }
            }
            List<StationObjModel> barcodeStationList = basDevp.getBarcodeStationList$();
            for (StationObjModel stationObjModel : barcodeStationList) {
                if (stationObjModel != null && stationObjModel.getStationId() != null) {
                    excludeStationSet.add(stationObjModel.getStationId());
                }
            }
        }
    }
    private JSONObject parseNodeValue(String nodeValue) {
        if (nodeValue == null || nodeValue.trim().isEmpty()) {
            return null;
        }
        try {
            return JSON.parseObject(nodeValue);
        } catch (Exception ignore) {
            return null;
        }
    }
    private boolean isExcludeStation(JSONObject valueObj) {
        Integer isInStation = valueObj.getInteger("isInStation");
        Integer isBarcodeStation = valueObj.getInteger("isBarcodeStation");
        return (isInStation != null && isInStation == 1)
                || (isBarcodeStation != null && isBarcodeStation == 1);
    }
    private Map<Integer, Integer> buildStationWorkNoMap() {
        Map<Integer, Integer> workNoMap = new HashMap<>();
        List<DeviceConfig> devpList = deviceConfigService.selectList(new EntityWrapper<DeviceConfig>()
                .eq("device_type", String.valueOf(SlaveType.Devp)));
        if (devpList == null || devpList.isEmpty()) {
            return workNoMap;
        }
        for (DeviceConfig deviceConfig : devpList) {
            StationThread stationThread = (StationThread) SlaveConnection.get(SlaveType.Devp, deviceConfig.getDeviceNo());
            if (stationThread == null) {
                continue;
            }
            Map<Integer, StationProtocol> statusMap = stationThread.getStatusMap();
            if (statusMap == null || statusMap.isEmpty()) {
                continue;
            }
            for (StationProtocol protocol : statusMap.values()) {
                if (protocol == null || protocol.getStationId() == null) {
                    continue;
                }
                Integer taskNo = protocol.getTaskNo();
                if (taskNo != null && taskNo > 0) {
                    workNoMap.put(protocol.getStationId(), taskNo);
                }
            }
        }
        return workNoMap;
    }
    private List<Set<Integer>> findStrongConnectedComponents(Map<Integer, Set<Integer>> graph) {
        List<Set<Integer>> result = new ArrayList<>();
        if (graph == null || graph.isEmpty()) {
            return result;
        }
        Map<Integer, Integer> indexMap = new HashMap<>();
        Map<Integer, Integer> lowLinkMap = new HashMap<>();
        Deque<Integer> stack = new ArrayDeque<>();
        Set<Integer> inStack = new HashSet<>();
        int[] index = new int[]{0};
        List<Integer> nodeList = new ArrayList<>(graph.keySet());
        Collections.sort(nodeList);
        for (Integer node : nodeList) {
            if (!indexMap.containsKey(node)) {
                strongConnect(node, graph, indexMap, lowLinkMap, stack, inStack, index, result);
            }
        }
        return result;
    }
    private void strongConnect(Integer node,
                               Map<Integer, Set<Integer>> graph,
                               Map<Integer, Integer> indexMap,
                               Map<Integer, Integer> lowLinkMap,
                               Deque<Integer> stack,
                               Set<Integer> inStack,
                               int[] index,
                               List<Set<Integer>> result) {
        indexMap.put(node, index[0]);
        lowLinkMap.put(node, index[0]);
        index[0]++;
        stack.push(node);
        inStack.add(node);
        List<Integer> nextList = new ArrayList<>(graph.getOrDefault(node, Collections.emptySet()));
        Collections.sort(nextList);
        for (Integer next : nextList) {
            if (!indexMap.containsKey(next)) {
                strongConnect(next, graph, indexMap, lowLinkMap, stack, inStack, index, result);
                lowLinkMap.put(node, Math.min(lowLinkMap.get(node), lowLinkMap.get(next)));
            } else if (inStack.contains(next)) {
                lowLinkMap.put(node, Math.min(lowLinkMap.get(node), indexMap.get(next)));
            }
        }
        if (!lowLinkMap.get(node).equals(indexMap.get(node))) {
            return;
        }
        Set<Integer> scc = new HashSet<>();
        while (!stack.isEmpty()) {
            Integer top = stack.pop();
            inStack.remove(top);
            scc.add(top);
            if (top.equals(node)) {
                break;
            }
        }
        result.add(scc);
    }
    private boolean isCycleScc(Set<Integer> scc, Map<Integer, Set<Integer>> graph) {
        if (scc == null || scc.isEmpty()) {
            return false;
        }
        if (scc.size() > 1) {
            return true;
        }
        Integer onlyNode = scc.iterator().next();
        Set<Integer> nextSet = graph.getOrDefault(onlyNode, Collections.emptySet());
        return nextSet.contains(onlyNode);
    }
    /**
     * ä»Ž SCC ä¸­æå–循环核心:
     * 1) è½¬æ— å‘图
     * 2) é€’归剥离度数<2的节点(2-core)
     * 3) å°†å‰©ä½™èŠ‚ç‚¹æ‹†æˆè¿žé€šåˆ†é‡ï¼Œæ¯ä¸ªåˆ†é‡>=3才认定为循环圈
     */
    private List<Set<Integer>> extractCoreLoopComponents(Set<Integer> scc, Map<Integer, Set<Integer>> graph) {
        List<Set<Integer>> result = new ArrayList<>();
        if (scc == null || scc.isEmpty()) {
            return result;
        }
        // æž„建 SCC å†…无向邻接
        Map<Integer, Set<Integer>> undirectedMap = new HashMap<>();
        for (Integer node : scc) {
            undirectedMap.put(node, new HashSet<>());
        }
        for (Integer from : scc) {
            Set<Integer> nextSet = graph.getOrDefault(from, Collections.emptySet());
            for (Integer to : nextSet) {
                if (!scc.contains(to) || from.equals(to)) {
                    continue;
                }
                undirectedMap.get(from).add(to);
                undirectedMap.get(to).add(from);
            }
        }
        // 2-core å‰¥ç¦»
        Set<Integer> alive = new HashSet<>(scc);
        Map<Integer, Integer> degreeMap = new HashMap<>();
        ArrayDeque<Integer> queue = new ArrayDeque<>();
        for (Integer node : scc) {
            int degree = undirectedMap.getOrDefault(node, Collections.emptySet()).size();
            degreeMap.put(node, degree);
            if (degree < 2) {
                queue.offer(node);
            }
        }
        while (!queue.isEmpty()) {
            Integer node = queue.poll();
            if (!alive.remove(node)) {
                continue;
            }
            for (Integer next : undirectedMap.getOrDefault(node, Collections.emptySet())) {
                if (!alive.contains(next)) {
                    continue;
                }
                int newDegree = degreeMap.getOrDefault(next, 0) - 1;
                degreeMap.put(next, newDegree);
                if (newDegree < 2) {
                    queue.offer(next);
                }
            }
        }
        if (alive.size() < 3) {
            return result;
        }
        // æ‹†åˆ†è¿žé€šåˆ†é‡
        Set<Integer> visited = new HashSet<>();
        List<Integer> sortedAlive = new ArrayList<>(alive);
        Collections.sort(sortedAlive);
        for (Integer start : sortedAlive) {
            if (!visited.add(start)) {
                continue;
            }
            Set<Integer> component = new HashSet<>();
            ArrayDeque<Integer> bfs = new ArrayDeque<>();
            bfs.offer(start);
            component.add(start);
            while (!bfs.isEmpty()) {
                Integer node = bfs.poll();
                for (Integer next : undirectedMap.getOrDefault(node, Collections.emptySet())) {
                    if (!alive.contains(next) || !visited.add(next)) {
                        continue;
                    }
                    component.add(next);
                    bfs.offer(next);
                }
            }
            // è‡³å°‘3个点才认为是真正“圈”
            if (component.size() >= 3) {
                result.add(component);
            }
        }
        return result;
    }
    private static class GraphContext {
        private final Map<Integer, Set<Integer>> graph = new HashMap<>();
        private final Set<Integer> excludeStationSet = new HashSet<>();
    }
}
src/main/java/com/zy/asrs/task/StationCycleCapacityScheduler.java
New file
@@ -0,0 +1,20 @@
package com.zy.asrs.task;
import com.zy.asrs.service.StationCycleCapacityService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class StationCycleCapacityScheduler {
    @Autowired
    private StationCycleCapacityService stationCycleCapacityService;
    // æ¯ç§’刷新一次循环圈承载量
    @Scheduled(cron = "0/1 * * * * ? ")
    public void refreshStationCycleCapacity() {
        stationCycleCapacityService.refreshSnapshot();
    }
}
src/main/java/com/zy/asrs/ws/ConsoleWebSocket.java
@@ -103,6 +103,9 @@
            } else if ("/console/latest/data/dualcrn".equals(url)) {
                ConsoleController consoleController = SpringUtils.getBean(ConsoleController.class);
                resObj = consoleController.dualCrnLatestData();
            } else if ("/console/latest/data/station/cycle/capacity".equals(url)) {
                ConsoleController consoleController = SpringUtils.getBean(ConsoleController.class);
                resObj = consoleController.stationCycleCapacity();
            } else if ("/crn/table/crn/state".equals(url)) {
                resObj = SpringUtils.getBean(CrnController.class).crnStateTable();
            } else if ("/rgv/table/rgv/state".equals(url)) {
src/main/java/com/zy/core/ServerBootstrap.java
@@ -135,6 +135,8 @@
                    thread = new ZyStationThread(deviceConfig, redisUtil);
                } else if (deviceConfig.getThreadImpl().equals("ZyStationV3Thread")) {
                    thread = new ZyStationV3Thread(deviceConfig, redisUtil);
                } else if (deviceConfig.getThreadImpl().equals("ZyStationV4Thread")) {
                    thread = new ZyStationV4Thread(deviceConfig, redisUtil);
                } else {
                    throw new CoolException("未知的线程实现");
                }
src/main/java/com/zy/core/enums/RedisKeyType.java
@@ -60,6 +60,7 @@
    CRN_OUT_TASK_COMPLETE_STATION_INFO("crn_out_task_complete_station_info_"),
    WATCH_CIRCLE_STATION_("watch_circle_station_"),
    STATION_CYCLE_LOAD_RESERVE("station_cycle_load_reserve"),
    CURRENT_CIRCLE_TASK_CRN_NO("current_circle_task_crn_no_"),
    ASYNC_WMS_IN_TASK_REQUEST("async_wms_in_task_request_"),
src/main/java/com/zy/core/model/command/StationCommand.java
@@ -19,6 +19,9 @@
    private List<Integer> navigatePath;
    // è·¯å¾„中的顶升移栽点(按路径顺序)
    private List<Integer> liftTransferPath;
    private List<Integer> originalNavigatePath;
    private StationCommandType commandType;
src/main/java/com/zy/core/network/ZyStationConnectDriver.java
@@ -11,8 +11,10 @@
import java.util.List;
import com.zy.core.network.fake.ZyStationFakeConnect;
import com.zy.core.network.fake.ZyStationFakeSegConnect;
import com.zy.core.network.fake.ZyStationV4FakeSegConnect;
import com.zy.core.network.real.ZyStationRealConnect;
import com.zy.core.network.real.ZyStationV3RealConnect;
import com.zy.core.network.real.ZyStationV4RealConnect;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
@@ -27,6 +29,7 @@
    private static final ZyStationFakeConnect zyStationFakeConnect = new ZyStationFakeConnect();
    private static final ZyStationFakeSegConnect zyStationFakeSegConnect = new ZyStationFakeSegConnect();
    private static final ZyStationV4FakeSegConnect zyStationV4FakeSegConnect = new ZyStationV4FakeSegConnect();
    private boolean connected = false;
    private DeviceConfig deviceConfig;
@@ -50,6 +53,8 @@
        if (deviceConfig.getFake() == 0) {
            if ("ZyStationV3Thread".equals(deviceConfig.getThreadImpl())) {
                zyStationConnectApi = new ZyStationV3RealConnect(deviceConfig, redisUtil);
            } else if ("ZyStationV4Thread".equals(deviceConfig.getThreadImpl())) {
                zyStationConnectApi = new ZyStationV4RealConnect(deviceConfig, redisUtil);
            } else {
                zyStationConnectApi = new ZyStationRealConnect(deviceConfig, redisUtil);
            }
@@ -57,6 +62,9 @@
            if ("ZyStationV3Thread".equals(deviceConfig.getThreadImpl())) {
                zyStationFakeSegConnect.addFakeConnect(deviceConfig, redisUtil);
                zyStationConnectApi = zyStationFakeSegConnect;
            } else if ("ZyStationV4Thread".equals(deviceConfig.getThreadImpl())) {
                zyStationV4FakeSegConnect.addFakeConnect(deviceConfig, redisUtil);
                zyStationConnectApi = zyStationV4FakeSegConnect;
            } else {
                zyStationFakeConnect.addFakeConnect(deviceConfig, redisUtil);
                zyStationConnectApi = zyStationFakeConnect;
src/main/java/com/zy/core/network/entity/ZyStationStatusEntity.java
@@ -50,6 +50,9 @@
    //重量
    private Double weight;
    //任务可写区
    private Integer taskWriteIdx;
    //运行堵塞
    private boolean runBlock = false;
src/main/java/com/zy/core/network/fake/ZyStationV4FakeSegConnect.java
New file
@@ -0,0 +1,8 @@
package com.zy.core.network.fake;
/**
 * è¾“送站 V4 ä»¿çœŸåˆ†æ®µè¿žæŽ¥å®žçŽ°ã€‚
 * å½“前复用 V3 åˆ†æ®µä»¿çœŸé€»è¾‘,保留独立类用于后续 V4 ä»¿çœŸç­–略演进。
 */
public class ZyStationV4FakeSegConnect extends ZyStationFakeSegConnect {
}
src/main/java/com/zy/core/network/real/ZyStationV4RealConnect.java
New file
@@ -0,0 +1,335 @@
package com.zy.core.network.real;
import HslCommunication.Core.Types.OperateResult;
import HslCommunication.Core.Types.OperateResultExOne;
import HslCommunication.Profinet.Siemens.SiemensPLCS;
import HslCommunication.Profinet.Siemens.SiemensS7Net;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.mapper.EntityWrapper;
import com.core.common.DateUtils;
import com.core.common.SpringUtils;
import com.zy.asrs.entity.BasDevp;
import com.zy.asrs.entity.DeviceConfig;
import com.zy.asrs.service.BasDevpService;
import com.zy.common.utils.RedisUtil;
import com.zy.core.News;
import com.zy.core.cache.OutputQueue;
import com.zy.core.model.CommandResponse;
import com.zy.core.model.StationObjModel;
import com.zy.core.model.command.StationCommand;
import com.zy.core.network.api.ZyStationConnectApi;
import com.zy.core.network.entity.ZyStationStatusEntity;
import lombok.extern.slf4j.Slf4j;
import java.text.MessageFormat;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
/**
 * è¾“送站真实连接(PLC)
 */
@Slf4j
public class ZyStationV4RealConnect implements ZyStationConnectApi {
    private List<ZyStationStatusEntity> statusList;
    private List<StationObjModel> barcodeOriginList;
    private SiemensS7Net siemensNet;
    private DeviceConfig deviceConfig;
    private RedisUtil redisUtil;
    public ZyStationV4RealConnect(DeviceConfig deviceConfig, RedisUtil redisUtil) {
        this.deviceConfig = deviceConfig;
        this.redisUtil = redisUtil;
    }
    @Override
    public boolean connect() {
        boolean connected = false;
        siemensNet = new SiemensS7Net(SiemensPLCS.S1200, deviceConfig.getIp());
        OperateResult connect = siemensNet.ConnectServer();
        if (connect.IsSuccess) {
            connected = true;
            OutputQueue.DEVP.offer(MessageFormat.format("【{0}】输送站plc连接成功 ===>> [id:{1}] [ip:{2}] [port:{3}]",
                    DateUtils.convert(new Date()), deviceConfig.getDeviceNo(), deviceConfig.getIp(),
                    deviceConfig.getPort()));
            News.info("输送站plc连接成功 ===>> [id:{}] [ip:{}] [port:{}]",
                    deviceConfig.getDeviceNo(), deviceConfig.getIp(), deviceConfig.getPort());
        } else {
            OutputQueue.DEVP.offer(MessageFormat.format("【{0}】输送站plc连接失败!!! ===>> [id:{1}] [ip:{2}] [port:{3}]",
                    DateUtils.convert(new Date()), deviceConfig.getDeviceNo(), deviceConfig.getIp(),
                    deviceConfig.getPort()));
            News.error("输送站plc连接失败!!! ===>> [id:{}] [ip:{}] [port:{}]",
                    deviceConfig.getDeviceNo(), deviceConfig.getIp(), deviceConfig.getPort());
        }
//        siemensNet.ConnectClose();
        return connected;
    }
    @Override
    public boolean disconnect() {
        siemensNet.ConnectClose();
        return true;
    }
    @Override
    public List<ZyStationStatusEntity> getStatus(Integer deviceNo) {
        if (statusList == null) {
            BasDevpService basDevpService = SpringUtils.getBean(BasDevpService.class);
            if (basDevpService == null) {
                return Collections.emptyList();
            }
            BasDevp basDevp = basDevpService
                    .selectOne(new EntityWrapper<BasDevp>().eq("devp_no", deviceConfig.getDeviceNo()));
            if (basDevp == null) {
                return Collections.emptyList();
            }
            statusList = JSONObject.parseArray(basDevp.getStationList(), ZyStationStatusEntity.class);
            if (statusList != null) {
                statusList.sort(Comparator.comparing(ZyStationStatusEntity::getStationId));
            }
            barcodeOriginList = basDevp.getBarcodeStationList$();
        }
        if (siemensNet == null) {
            return statusList;
        }
        OperateResultExOne<byte[]> result = siemensNet.Read("DB100.0", (short) (statusList.size() * 10));
        if (result.IsSuccess) {
            for (int i = 0; i < statusList.size(); i++) {
                ZyStationStatusEntity statusEntity = statusList.get(i); // ç«™ç‚¹ç¼–号
                statusEntity.setTaskNo(siemensNet.getByteTransform().TransInt32(result.Content, i * 10)); // å·¥ä½œå·
                statusEntity.setTargetStaNo((int) siemensNet.getByteTransform().TransInt16(result.Content, i * 10 + 4)); // ç›®æ ‡ç«™
                boolean[] status = siemensNet.getByteTransform().TransBool(result.Content, i * 10 + 6, 1);
                statusEntity.setAutoing(status[0]); // è‡ªåЍ
                statusEntity.setLoading(status[1]); // æœ‰ç‰©
                statusEntity.setInEnable(status[2]); // å¯å…¥
                statusEntity.setOutEnable(status[3]);// å¯å‡º
                statusEntity.setEmptyMk(status[4]); // ç©ºæ‰˜ç›˜
                statusEntity.setFullPlt(status[5]); // æ»¡æ‰˜ç›˜
                boolean[] status2 = siemensNet.getByteTransform().TransBool(result.Content, i * 10 + 7, 1);
                statusEntity.setEnableIn(status2[1]);//启动入库
                Integer palletHeight = null;
                if (status[7]) {
                    palletHeight = 1;//低
                }
                if (status2[0]) {
                    palletHeight = 2;//中
                }
                if (status[6]) {
                    palletHeight = 3;//高
                }
                statusEntity.setPalletHeight(palletHeight);//高低信号
                statusEntity.setError(0);//默认无报警
                statusEntity.setTaskWriteIdx((int) siemensNet.getByteTransform().TransInt16(result.Content, i * 10 + 8));//任务可写区
            }
        }
        // æ¡ç æ‰«æå™¨
        OperateResultExOne<byte[]> result2 = siemensNet.Read("DB101.16", (short) (barcodeOriginList.size() * 16));
        if (result2.IsSuccess) {
            for (int i = 0; i < barcodeOriginList.size(); i++) {
                ZyStationStatusEntity barcodeEntity = findStatusEntityByBarcodeIdx(i + 1);
                if (barcodeEntity == null) {
                    continue;
                }
                String barcode = siemensNet.getByteTransform().TransString(result2.Content, i * 16 + 2, 14, "UTF-8");
                barcode = barcode.trim();
                barcodeEntity.setBarcode(barcode);
            }
        }
        // ç§°é‡
        OperateResultExOne<byte[]> result3 = siemensNet.Read("DB102.4", (short) (barcodeOriginList.size() * 4));
        if (result3.IsSuccess) {
            for (int i = 0; i < barcodeOriginList.size(); i++) {
                ZyStationStatusEntity barcodeEntity = findStatusEntityByBarcodeIdx(i + 1);
                if (barcodeEntity == null) {
                    continue;
                }
                double weight = (double) siemensNet.getByteTransform().TransSingle(result3.Content, i * 4);
                barcodeEntity.setWeight(weight);
            }
        }
        // æŠ¥è­¦ä¿¡æ¯
        OperateResultExOne<byte[]> result4 = siemensNet.Read("DB103.2", (short) (barcodeOriginList.size() * 2));
        if (result4.IsSuccess) {
            for (int i = 0; i < barcodeOriginList.size(); i++) {
                ZyStationStatusEntity barcodeEntity = findStatusEntityByBarcodeIdx(i + 1);
                if (barcodeEntity == null) {
                    continue;
                }
                StringBuilder sb = new StringBuilder();
                boolean[] status1 = siemensNet.getByteTransform().TransBool(result4.Content, i * 2, 1);
                boolean[] status2 = siemensNet.getByteTransform().TransBool(result4.Content, i * 2 + 1, 1);
                if(status1[0]){
                    sb.append("左超宽报警;");
                }
                if(status1[1]) {
                    sb.append("右超宽报警;");
                }
                if(status1[2]) {
                    sb.append("前超长报警;");
                }
                if(status1[3]) {
                    sb.append("后超长报警;");
                }
                if(status1[4]) {
                    sb.append("超高报警;");
                }
                if(status1[5]) {
                    sb.append("有货报警,空托入库时检测托盘上有无货物;");
                }
                if(status1[6]) {
                    sb.append("重量异常报警;");
                }
                if(status1[7]) {
                    sb.append("扫码异常;");
                }
                if(sb.length() > 0) {
                    barcodeEntity.setError(1);
                }else {
                    barcodeEntity.setError(0);
                }
                barcodeEntity.setErrorMsg(sb.toString());
            }
        }
        return statusList;
    }
    @Override
    public CommandResponse sendCommand(Integer deviceNo, StationCommand command) {
        CommandResponse commandResponse = new CommandResponse(false);
        if (null == command) {
            commandResponse.setMessage("命令为空");
            return commandResponse;
        }
        int taskWriteIdx = getTaskWriteIdx(command.getStationId());
        if (taskWriteIdx == -1) {
            commandResponse.setMessage("命令下发超时,无法找到可用下发区域");
            return commandResponse;
        }
        int stationIdx = findIndex(command.getStationId());
        short[] data = new short[2];
        data[0] = command.getStationId().shortValue();
        data[1] = command.getTargetStaNo().shortValue();
        OperateResult writeTaskNo = siemensNet.Write("DB13." + (stationIdx * 48 + (taskWriteIdx * 12)), command.getTaskNo());
        if (!writeTaskNo.IsSuccess) {
            log.error("写入输送线命令失败。站点编号={},站点数据={}", command.getTaskNo(), JSON.toJSON(command));
            commandResponse.setResult(false);
            commandResponse.setMessage("命令下发失败,写入工作号失败");
            return commandResponse;
        }
        OperateResult writeData = siemensNet.Write("DB13." + (stationIdx * 48 + (taskWriteIdx * 12 + 4)), data);
        if (!writeData.IsSuccess) {
            log.error("写入输送线命令失败。站点编号={},站点数据={}", command.getTaskNo(), JSON.toJSON(command));
            commandResponse.setResult(false);
            commandResponse.setMessage("命令下发失败,写入数据区域失败");
            return commandResponse;
        }
        log.info("写入输送线命令成功。任务号={},站点数据={}", command.getTaskNo(), JSON.toJSON(command));
        commandResponse.setResult(true);
        return commandResponse;
    }
    @Override
    public synchronized CommandResponse sendOriginCommand(String address, short[] data) {
        CommandResponse commandResponse = new CommandResponse(false);
        if (null == data || data.length == 0) {
            commandResponse.setMessage("数据为空");
            return commandResponse;
        }
        OperateResult write = siemensNet.Write(address, data);
        if (write.IsSuccess) {
            log.info("写入原始命令成功。地址={},数据={}", address, JSON.toJSON(data));
            commandResponse.setResult(true);
        } else {
            log.error("写入原始命令失败。地址={},数据={}", address, JSON.toJSON(data));
            commandResponse.setResult(false);
        }
        return commandResponse;
    }
    @Override
    public byte[] readOriginCommand(String address, int length) {
        OperateResultExOne<byte[]> result = siemensNet.Read(address, (short) length);
        if (result.IsSuccess) {
            return result.Content;
        }
        return new byte[0];
    }
    private ZyStationStatusEntity findStatusEntityByBarcodeIdx(Integer barcodeIdx) {
        Integer stationId = null;
        for (StationObjModel stationObjModel : barcodeOriginList) {
            if (stationObjModel.getBarcodeIdx().equals(barcodeIdx)) {
                stationId = stationObjModel.getStationId();
                break;
            }
        }
        for (ZyStationStatusEntity zyStationStatusEntity : statusList) {
            if(zyStationStatusEntity.getStationId().equals(stationId)) {
                return zyStationStatusEntity;
            }
        }
        return null;
    }
    private int getTaskWriteIdx(int stationId) {
        int useIdx = -1;
        int stationIdx = findIndex(stationId);
        if (stationIdx != -1) {
            ZyStationStatusEntity statusEntity = statusList.get(stationIdx);
            Integer taskWriteIdx = statusEntity.getTaskWriteIdx();
            if (taskWriteIdx > 0) {
                OperateResultExOne<byte[]> resultTask = siemensNet.Read("DB13." + (stationId * 48), (short) 48);
                if (resultTask.IsSuccess) {
                    int taskNo = siemensNet.getByteTransform().TransInt32(resultTask.Content, taskWriteIdx * 12);
                    int startPoint = siemensNet.getByteTransform().TransInt16(resultTask.Content, taskWriteIdx * 12 + 4);
                    int targetPoint = siemensNet.getByteTransform().TransInt16(resultTask.Content, taskWriteIdx * 12 + 6);
                    if (taskNo == 0 && startPoint == 0 && targetPoint == 0) {
                        useIdx = taskWriteIdx;
                    }
                }
            }
        }
        return useIdx;
    }
    private int findIndex(Integer stationId) {
        for (int i = 0; i < statusList.size(); i++) {
            ZyStationStatusEntity statusEntity = statusList.get(i);
            if (statusEntity.getStationId().equals(stationId)) {
                return i;
            }
        }
        return -1;
    }
}
src/main/java/com/zy/core/thread/impl/ZyStationV4Thread.java
@@ -213,8 +213,25 @@
        if (commandType == StationCommandType.MOVE) {
            if (!stationId.equals(targetStationId)) {
                List<Integer> path = calcPathStationIds(stationId, targetStationId);
                List<NavigateNode> nodes = calcPathNavigateNodes(stationId, targetStationId);
                List<Integer> path = new ArrayList<>();
                List<Integer> liftTransferPath = new ArrayList<>();
                for (NavigateNode n : nodes) {
                    JSONObject v = JSONObject.parseObject(n.getNodeValue());
                    if (v == null) {
                        continue;
                    }
                    Integer stationNo = v.getInteger("stationId");
                    if (stationNo == null) {
                        continue;
                    }
                    path.add(stationNo);
                    if (Boolean.TRUE.equals(n.getIsLiftTransferPoint())) {
                        liftTransferPath.add(stationNo);
                    }
                }
                stationCommand.setNavigatePath(path);
                stationCommand.setLiftTransferPath(liftTransferPath);
            }
        }
        return stationCommand;
@@ -271,65 +288,88 @@
        return zyStationConnectDriver.readOriginCommand(address, length);
    }
    private List<Integer> calcPathStationIds(Integer startStationId, Integer targetStationId) {
    private List<NavigateNode> calcPathNavigateNodes(Integer startStationId, Integer targetStationId) {
        NavigateUtils navigateUtils = SpringUtils.getBean(NavigateUtils.class);
        if (navigateUtils == null) {
            return new ArrayList<>();
        }
        List<NavigateNode> nodes = navigateUtils.calcByStationId(startStationId, targetStationId);
        List<Integer> ids = new ArrayList<>();
        for (NavigateNode n : nodes) {
            JSONObject v = JSONObject.parseObject(n.getNodeValue());
            if (v != null) {
                ids.add(v.getInteger("stationId"));
            }
        }
        return ids;
        return navigateUtils.calcByStationId(startStationId, targetStationId);
    }
    private void executeMoveWithSeg(StationCommand original) {
        int stationCommandSendLength = 20;
        Object systemConfigMapObj = redisUtil.get(RedisKeyType.SYSTEM_CONFIG_MAP.key);
        if (systemConfigMapObj != null) {
            try {
                HashMap<String, String> systemConfigMap = (HashMap<String, String>) systemConfigMapObj;
                String stationCommandSendLengthStr = systemConfigMap.get("stationCommandSendLength");
                if(stationCommandSendLengthStr != null){
                    stationCommandSendLength = Integer.parseInt(stationCommandSendLengthStr);
                }
            } catch (Exception ignore) {}
        }
        if(original.getCommandType() == StationCommandType.MOVE){
            List<Integer> path = JSON.parseArray(JSON.toJSONString(original.getNavigatePath(), SerializerFeature.DisableCircularReferenceDetect), Integer.class);
            List<Integer> liftTransferPath = JSON.parseArray(JSON.toJSONString(original.getLiftTransferPath(), SerializerFeature.DisableCircularReferenceDetect), Integer.class);
            if (path == null || path.isEmpty()) {
                return;
            }
            int total = path.size();
            List<Integer> segmentTargets = new ArrayList<>();
            List<Integer> segmentEndIndices = new ArrayList<>();
            int idx = 0;
            while (idx < total) {
                int end = Math.min(idx + stationCommandSendLength, total) - 1;
                segmentTargets.add(path.get(end));
                segmentEndIndices.add(end);
                idx = end + 1;
            if (liftTransferPath != null) {
                for (Integer liftTransferStationId : liftTransferPath) {
                    int endIndex = path.indexOf(liftTransferStationId);
                    // é¿å…ä»¥èµ·ç‚¹ä½œä¸ºåˆ‡ç‚¹å¯¼è‡´ç©ºåˆ†æ®µ
                    if (endIndex <= 0) {
                        continue;
                    }
                    if (segmentEndIndices.isEmpty() || endIndex > segmentEndIndices.get(segmentEndIndices.size() - 1)) {
                        segmentEndIndices.add(endIndex);
                    }
                }
            }
            if (segmentEndIndices.isEmpty() || segmentEndIndices.get(segmentEndIndices.size() - 1) != total - 1) {
                segmentEndIndices.add(total - 1);
            }
            List<StationCommand> segmentCommands = new ArrayList<>();
            int buildStartIdx = 0;
            for (Integer endIdx : segmentEndIndices) {
                if (endIdx == null || endIdx < buildStartIdx) {
                    continue;
                }
                List<Integer> segmentPath = new ArrayList<>(path.subList(buildStartIdx, endIdx + 1));
                if (segmentPath.isEmpty()) {
                    buildStartIdx = endIdx + 1;
                    continue;
                }
                StationCommand segmentCommand = new StationCommand();
                segmentCommand.setTaskNo(original.getTaskNo());
                segmentCommand.setCommandType(original.getCommandType());
                segmentCommand.setPalletSize(original.getPalletSize());
                segmentCommand.setBarcode(original.getBarcode());
                segmentCommand.setOriginalNavigatePath(path);
                segmentCommand.setNavigatePath(segmentPath);
                // æ¯æ®µå‘½ä»¤ï¼šèµ·ç‚¹=当前段首站点,终点=当前段末站点
                segmentCommand.setStationId(segmentPath.get(0));
                segmentCommand.setTargetStaNo(segmentPath.get(segmentPath.size() - 1));
                segmentCommands.add(segmentCommand);
                // åˆ†æ®µè¾¹ç•Œç‚¹éœ€è¦åŒæ—¶ä½œä¸ºä¸‹ä¸€æ®µçš„起点(例如 [221,220,219] + [219,213,212])
                buildStartIdx = endIdx;
            }
            if (segmentCommands.isEmpty()) {
                return;
            }
            int segCursor = 0;
            Integer currentTarget = segmentTargets.get(segCursor);
            Integer currentEndIdx = segmentEndIndices.get(segCursor);
            Integer currentStartIdx = 0;
            StationCommand segCmd = new StationCommand();
            segCmd.setTaskNo(original.getTaskNo());
            segCmd.setStationId(original.getStationId());
            segCmd.setTargetStaNo(original.getTargetStaNo());
            segCmd.setCommandType(original.getCommandType());
            segCmd.setPalletSize(original.getPalletSize());
            segCmd.setNavigatePath(new ArrayList<>(path.subList(0, currentEndIdx + 1)));
            sendCommand(segCmd);
            while (true) {
                CommandResponse commandResponse = sendCommand(segmentCommands.get(segCursor));
                if (commandResponse == null) {
                    try {
                        Thread.sleep(200);
                    } catch (Exception ignore) {}
                    continue;
                }
                if (commandResponse.getResult()) {
                    break;
                }
                try {
                    Thread.sleep(200);
                } catch (Exception ignore) {}
            }
            long runTime = System.currentTimeMillis();
            boolean firstRun = true;
@@ -362,26 +402,15 @@
                    if (remaining <= 0) {
                        break;
                    }
                    int currentSegEndIndex = path.indexOf(segmentTargets.get(segCursor));
                    int currentSegStartIndex = segCursor == 0 ? 0 : path.indexOf(segmentTargets.get(segCursor - 1)) + 1;
                    int currentSegEndIndex = segmentEndIndices.get(segCursor);
                    int currentSegStartIndex = segCursor == 0 ? 0 : segmentEndIndices.get(segCursor - 1);
                    int segLen = currentSegEndIndex - currentSegStartIndex + 1;
                    int remainingSegment = Math.max(0, currentSegEndIndex - currentIndex);
                    int thresholdSegment = (int) Math.ceil(segLen * 0.3);
                    if (remainingSegment <= thresholdSegment && segCursor < segmentTargets.size() - 1) {
                    if (remainingSegment <= thresholdSegment && segCursor < segmentCommands.size() - 1) {
                        segCursor++;
                        currentEndIdx = segmentEndIndices.get(segCursor);
                        currentStartIdx = segmentEndIndices.get(segCursor - 1) + 1;
                        StationCommand nextCmd = new StationCommand();
                        nextCmd.setTaskNo(original.getTaskNo());
                        nextCmd.setStationId(original.getStationId());
                        nextCmd.setTargetStaNo(original.getTargetStaNo());
                        nextCmd.setCommandType(original.getCommandType());
                        nextCmd.setPalletSize(original.getPalletSize());
                        nextCmd.setNavigatePath(new ArrayList<>(path.subList(currentStartIdx, currentEndIdx + 1)));
                        nextCmd.setOriginalNavigatePath(path);
                        while (true) {
                            CommandResponse commandResponse = sendCommand(nextCmd);
                            CommandResponse commandResponse = sendCommand(segmentCommands.get(segCursor));
                            if (commandResponse == null) {
                                Thread.sleep(200);
                                continue;
src/main/java/com/zy/core/utils/CrnOperateProcessUtils.java
@@ -54,6 +54,8 @@
    private CommonService commonService;
    @Autowired
    private NotifyUtils notifyUtils;
    @Autowired
    private StationOperateProcessUtils stationOperateProcessUtils;
    public synchronized void crnIoExecute() {
        Object systemConfigMapObj = redisUtil.get(RedisKeyType.SYSTEM_CONFIG_MAP.key);
@@ -258,6 +260,21 @@
            return false;
        }
        int stationMaxTaskCount = 30;
        Object systemConfigMapObj = redisUtil.get(RedisKeyType.SYSTEM_CONFIG_MAP.key);
        if (systemConfigMapObj != null) {
            try {
                HashMap<String, String> systemConfigMap = (HashMap<String, String>) systemConfigMapObj;
                stationMaxTaskCount = Integer.parseInt(systemConfigMap.getOrDefault("stationMaxTaskCountLimit", "30"));
            } catch (Exception ignore) {}
        }
        int currentStationTaskCount = stationOperateProcessUtils.getCurrentStationTaskCount();
        if (stationMaxTaskCount > 0 && currentStationTaskCount >= stationMaxTaskCount) {
            News.warn("输送站点任务数量达到上限,已停止任务下发。当前任务数={},上限={}", currentStationTaskCount, stationMaxTaskCount);
            return false;
        }
        Integer crnNo = basCrnp.getCrnNo();
        List<WrkMast> wrkMasts = wrkMastService.selectList(new EntityWrapper<WrkMast>()
src/main/java/com/zy/core/utils/StationOperateProcessUtils.java
@@ -7,6 +7,8 @@
import com.core.common.Cools;
import com.core.exception.CoolException;
import com.zy.asrs.domain.enums.NotifyMsgType;
import com.zy.asrs.domain.vo.StationCycleCapacityVo;
import com.zy.asrs.domain.vo.StationCycleLoopVo;
import com.zy.asrs.entity.*;
import com.zy.asrs.service.*;
import com.zy.asrs.utils.NotifyUtils;
@@ -32,6 +34,7 @@
@Component
public class StationOperateProcessUtils {
    private static final int LOOP_LOAD_RESERVE_EXPIRE_SECONDS = 120;
    @Autowired
    private BasDevpService basDevpService;
@@ -51,10 +54,16 @@
    private NavigateUtils navigateUtils;
    @Autowired
    private BasStationService basStationService;
    @Autowired
    private StationCycleCapacityService stationCycleCapacityService;
    //执行输送站点入库任务
    public synchronized void stationInExecute() {
        try {
            DispatchLimitConfig limitConfig = getDispatchLimitConfig();
            int[] currentStationTaskCountRef = new int[]{countCurrentStationTask()};
            LoadGuardState loadGuardState = buildLoadGuardState(limitConfig);
            List<BasDevp> basDevps = basDevpService.selectList(new EntityWrapper<>());
            for (BasDevp basDevp : basDevps) {
                StationThread stationThread = (StationThread) SlaveConnection.get(SlaveType.Devp, basDevp.getDevpNo());
@@ -109,6 +118,12 @@
                            continue;
                        }
                        LoopHitResult loopHitResult = findPathLoopHit(limitConfig, stationProtocol.getStationId(), targetStationId, loadGuardState);
                        if (isDispatchBlocked(limitConfig, currentStationTaskCountRef[0], loadGuardState, loopHitResult.isThroughLoop())) {
                            return;
                        }
                        StationCommand command = stationThread.getCommand(StationCommandType.MOVE, wrkMast.getWrkNo(), stationId, targetStationId, 0);
                        if (command == null) {
                            News.taskInfo(wrkMast.getWrkNo(), "{}工作,获取输送线命令失败", wrkMast.getWrkNo());
@@ -124,6 +139,8 @@
                            MessageQueue.offer(SlaveType.Devp, basDevp.getDevpNo(), new Task(2, command));
                            News.info("输送站点入库命令下发成功,站点号={},工作号={},命令数据={}", stationId, wrkMast.getWrkNo(), JSON.toJSONString(command));
                            redisUtil.set(RedisKeyType.STATION_IN_EXECUTE_LIMIT.key + stationId, "lock", 5);
                            loadGuardState.reserveLoopTask(loopHitResult.getLoopNo());
                            saveLoopLoadReserve(wrkMast.getWrkNo(), loopHitResult);
                        }
                    }
                }
@@ -136,6 +153,10 @@
    //执行堆垛机输送站点出库任务
    public synchronized void crnStationOutExecute() {
        try {
            DispatchLimitConfig limitConfig = getDispatchLimitConfig();
            int[] currentStationTaskCountRef = new int[]{countCurrentStationTask()};
            LoadGuardState loadGuardState = buildLoadGuardState(limitConfig);
            List<WrkMast> wrkMasts = wrkMastService.selectList(new EntityWrapper<WrkMast>()
                    .eq("wrk_sts", WrkStsType.OUTBOUND_RUN_COMPLETE.sts)
                    .isNotNull("crn_no")
@@ -188,6 +209,12 @@
                        }
                    }
                    LoopHitResult loopHitResult = findPathLoopHit(limitConfig, stationProtocol.getStationId(), moveStaNo, loadGuardState);
                    if (isDispatchBlocked(limitConfig, currentStationTaskCountRef[0], loadGuardState, loopHitResult.isThroughLoop())) {
                        return;
                    }
                    StationCommand command = stationThread.getCommand(StationCommandType.MOVE, wrkMast.getWrkNo(), stationProtocol.getStationId(), moveStaNo, 0);
                    if (command == null) {
                        News.taskInfo(wrkMast.getWrkNo(), "获取输送线命令失败");
@@ -202,6 +229,9 @@
                        News.info("输送站点出库命令下发成功,站点号={},工作号={},命令数据={}", stationProtocol.getStationId(), wrkMast.getWrkNo(), JSON.toJSONString(command));
                        redisUtil.set(RedisKeyType.STATION_OUT_EXECUTE_LIMIT.key + stationProtocol.getStationId(), "lock", 5);
                        redisUtil.del(RedisKeyType.CRN_OUT_TASK_COMPLETE_STATION_INFO.key + wrkMast.getWrkNo());
                        currentStationTaskCountRef[0]++;
                        loadGuardState.reserveLoopTask(loopHitResult.getLoopNo());
                        saveLoopLoadReserve(wrkMast.getWrkNo(), loopHitResult);
                    }
                }
            }
@@ -495,22 +525,7 @@
    //获取输送线任务数量
    public synchronized int getCurrentStationTaskCount() {
        int currentStationTaskCount = 0;
        List<BasDevp> basDevps = basDevpService.selectList(new EntityWrapper<BasDevp>());
        for (BasDevp basDevp : basDevps) {
            StationThread stationThread = (StationThread) SlaveConnection.get(SlaveType.Devp, basDevp.getId());
            if (stationThread == null) {
                continue;
            }
            for (StationProtocol stationProtocol : stationThread.getStatus()) {
                if (stationProtocol.getTaskNo() > 0) {
                    currentStationTaskCount++;
                }
            }
        }
        return currentStationTaskCount;
        return countCurrentStationTask();
    }
    // æ£€æµ‹å‡ºåº“排序
@@ -756,4 +771,306 @@
        return seq;
    }
    private int countCurrentStationTask() {
        int currentStationTaskCount = 0;
        List<BasDevp> basDevps = basDevpService.selectList(new EntityWrapper<BasDevp>());
        for (BasDevp basDevp : basDevps) {
            StationThread stationThread = (StationThread) SlaveConnection.get(SlaveType.Devp, basDevp.getDevpNo());
            if (stationThread == null) {
                continue;
            }
            for (StationProtocol stationProtocol : stationThread.getStatus()) {
                if (stationProtocol.getTaskNo() > 0) {
                    currentStationTaskCount++;
                }
            }
        }
        return currentStationTaskCount;
    }
    private boolean isDispatchBlocked(DispatchLimitConfig config,
                                      int currentStationTaskCount,
                                      LoadGuardState loadGuardState,
                                      boolean needReserveLoopLoad) {
        if (config.loopModeEnable) {
            double currentLoad = loadGuardState.currentLoad();
            if (currentLoad >= config.circleMaxLoadLimit) {
                News.warn("当前承载量达到上限,已停止站点任务下发。当前承载量={},上限={}", formatPercent(currentLoad), formatPercent(config.circleMaxLoadLimit));
                return true;
            }
            if (needReserveLoopLoad) {
                double reserveLoad = loadGuardState.loadAfterReserve();
                if (reserveLoad >= config.circleMaxLoadLimit) {
                    News.warn("预占后承载量达到上限,已停止站点任务下发。预占后承载量={},上限={}", formatPercent(reserveLoad), formatPercent(config.circleMaxLoadLimit));
                    return true;
                }
            }
        }
        return false;
    }
    private LoadGuardState buildLoadGuardState(DispatchLimitConfig config) {
        LoadGuardState state = new LoadGuardState();
        if (!config.loopModeEnable) {
            return state;
        }
        StationCycleCapacityVo capacityVo = stationCycleCapacityService.getLatestSnapshot();
        if (capacityVo == null) {
            return state;
        }
        state.totalStationCount = toNonNegative(capacityVo.getTotalStationCount());
        state.projectedTaskStationCount = toNonNegative(capacityVo.getTaskStationCount());
        List<StationCycleLoopVo> loopList = capacityVo.getLoopList();
        if (loopList != null) {
            for (StationCycleLoopVo loopVo : loopList) {
                if (loopVo == null || loopVo.getStationIdList() == null) {
                    continue;
                }
                Integer loopNo = loopVo.getLoopNo();
                for (Integer stationId : loopVo.getStationIdList()) {
                    if (stationId != null) {
                        if (loopNo != null) {
                            state.stationLoopNoMap.put(stationId, loopNo);
                        }
                    }
                }
            }
        }
        return state;
    }
    private LoopHitResult findPathLoopHit(DispatchLimitConfig config,
                                          Integer sourceStationId,
                                          Integer targetStationId,
                                          LoadGuardState loadGuardState) {
        if (!config.loopModeEnable) {
            return LoopHitResult.NO_HIT;
        }
        if (sourceStationId == null || targetStationId == null) {
            return LoopHitResult.NO_HIT;
        }
        if (loadGuardState.stationLoopNoMap.isEmpty()) {
            return LoopHitResult.NO_HIT;
        }
        try {
            List<NavigateNode> nodes = navigateUtils.calcByStationId(sourceStationId, targetStationId);
            if (nodes == null || nodes.isEmpty()) {
                return LoopHitResult.NO_HIT;
            }
            for (NavigateNode node : nodes) {
                Integer stationId = getStationIdFromNode(node);
                if (stationId == null) {
                    continue;
                }
                Integer loopNo = loadGuardState.stationLoopNoMap.get(stationId);
                if (loopNo != null) {
                    return new LoopHitResult(true, loopNo, stationId);
                }
            }
        } catch (Exception e) {
            return LoopHitResult.NO_HIT;
        }
        return LoopHitResult.NO_HIT;
    }
    private Integer getStationIdFromNode(NavigateNode node) {
        if (node == null || isBlank(node.getNodeValue())) {
            return null;
        }
        try {
            JSONObject v = JSONObject.parseObject(node.getNodeValue());
            if (v == null) {
                return null;
            }
            return v.getInteger("stationId");
        } catch (Exception e) {
            return null;
        }
    }
    private int toNonNegative(Integer value) {
        if (value == null || value < 0) {
            return 0;
        }
        return value;
    }
    private void saveLoopLoadReserve(Integer wrkNo, LoopHitResult loopHitResult) {
        if (wrkNo == null || wrkNo <= 0 || loopHitResult == null || !loopHitResult.isThroughLoop()) {
            return;
        }
        JSONObject reserveJson = new JSONObject();
        reserveJson.put("wrkNo", wrkNo);
        reserveJson.put("loopNo", loopHitResult.getLoopNo());
        reserveJson.put("hitStationId", loopHitResult.getHitStationId());
        reserveJson.put("createTime", System.currentTimeMillis());
        redisUtil.hset(RedisKeyType.STATION_CYCLE_LOAD_RESERVE.key, String.valueOf(wrkNo), reserveJson.toJSONString());
        redisUtil.expire(RedisKeyType.STATION_CYCLE_LOAD_RESERVE.key, LOOP_LOAD_RESERVE_EXPIRE_SECONDS);
    }
    private DispatchLimitConfig getDispatchLimitConfig() {
        DispatchLimitConfig config = new DispatchLimitConfig();
        Object systemConfigMapObj = redisUtil.get(RedisKeyType.SYSTEM_CONFIG_MAP.key);
        if (!(systemConfigMapObj instanceof Map)) {
            return config;
        }
        Map<?, ?> systemConfigMap = (Map<?, ?>) systemConfigMapObj;
        config.circleMaxLoadLimit = parseLoadLimit(getConfigValue(systemConfigMap, "circleMaxLoadLimit"), config.circleMaxLoadLimit);
        String loopModeValue = getConfigValue(systemConfigMap, "circleLoopModeEnable");
        if (isBlank(loopModeValue)) {
            loopModeValue = getConfigValue(systemConfigMap, "circleModeEnable");
        }
        if (isBlank(loopModeValue)) {
            loopModeValue = getConfigValue(systemConfigMap, "isCircleMode");
        }
        config.loopModeEnable = parseBoolean(loopModeValue, config.loopModeEnable);
        return config;
    }
    private String getConfigValue(Map<?, ?> configMap, String key) {
        Object value = configMap.get(key);
        if (value == null) {
            return null;
        }
        return String.valueOf(value).trim();
    }
    private boolean parseBoolean(String value, boolean defaultValue) {
        if (isBlank(value)) {
            return defaultValue;
        }
        String lowValue = value.toLowerCase(Locale.ROOT);
        if ("y".equals(lowValue) || "yes".equals(lowValue) || "true".equals(lowValue)
                || "1".equals(lowValue) || "on".equals(lowValue)) {
            return true;
        }
        if ("n".equals(lowValue) || "no".equals(lowValue) || "false".equals(lowValue)
                || "0".equals(lowValue) || "off".equals(lowValue)) {
            return false;
        }
        return defaultValue;
    }
    private double parseLoadLimit(String value, double defaultValue) {
        if (isBlank(value)) {
            return defaultValue;
        }
        try {
            String normalized = value.replace("%", "").trim();
            double parsed = Double.parseDouble(normalized);
            if (parsed > 1.0) {
                parsed = parsed / 100.0;
            }
            if (parsed < 0.0) {
                return 0.0;
            }
            if (parsed > 1.0) {
                return 1.0;
            }
            return parsed;
        } catch (Exception e) {
            return defaultValue;
        }
    }
    private int parseInt(String value, int defaultValue) {
        if (isBlank(value)) {
            return defaultValue;
        }
        try {
            int parsed = Integer.parseInt(value.trim());
            return parsed < 0 ? defaultValue : parsed;
        } catch (Exception e) {
            return defaultValue;
        }
    }
    private String formatPercent(double value) {
        return String.format(Locale.ROOT, "%.1f%%", value * 100.0);
    }
    private boolean isBlank(String value) {
        return value == null || value.trim().isEmpty();
    }
    private static class DispatchLimitConfig {
        // åœˆæœ€å¤§æ‰¿è½½èƒ½åŠ›ï¼Œé»˜è®¤80%
        private double circleMaxLoadLimit = 0.8d;
        // æ˜¯å¦å¯ç”¨ç»•圈模式(仅启用时才生效承载限制)
        private boolean loopModeEnable = false;
    }
    private static class LoadGuardState {
        private int totalStationCount = 0;
        private int projectedTaskStationCount = 0;
        private final Map<Integer, Integer> stationLoopNoMap = new HashMap<>();
        private double currentLoad() {
            return calcLoad(this.projectedTaskStationCount, this.totalStationCount);
        }
        private double loadAfterReserve() {
            return calcLoad(this.projectedTaskStationCount + 1, this.totalStationCount);
        }
        private void reserveLoopTask(Integer loopNo) {
            if (loopNo == null || loopNo <= 0) {
                return;
            }
            if (this.totalStationCount <= 0) {
                return;
            }
            this.projectedTaskStationCount++;
        }
        private double calcLoad(int taskCount, int stationCount) {
            if (stationCount <= 0 || taskCount <= 0) {
                return 0.0;
            }
            double load = (double) taskCount / (double) stationCount;
            if (load < 0.0) {
                return 0.0;
            }
            if (load > 1.0) {
                return 1.0;
            }
            return load;
        }
    }
    private static class LoopHitResult {
        private static final LoopHitResult NO_HIT = new LoopHitResult(false, null, null);
        private final boolean throughLoop;
        private final Integer loopNo;
        private final Integer hitStationId;
        private LoopHitResult(boolean throughLoop, Integer loopNo, Integer hitStationId) {
            this.throughLoop = throughLoop;
            this.loopNo = loopNo;
            this.hitStationId = hitStationId;
        }
        private boolean isThroughLoop() {
            return throughLoop;
        }
        private Integer getLoopNo() {
            return loopNo;
        }
        private Integer getHitStationId() {
            return hitStationId;
        }
    }
}
src/main/resources/application.yml
@@ -1,6 +1,6 @@
# ç³»ç»Ÿç‰ˆæœ¬ä¿¡æ¯
app:
  version: 1.0.4.4
  version: 1.0.4.6
  version-type: dev  # prd æˆ– dev
server:
src/main/resources/sql/·ÂÕæ²âÊÔ±¸·Ý_1.0.4.6.nb3
Binary files differ
src/main/webapp/components/MapCanvas.js
@@ -2,6 +2,20 @@
  template: `
    <div style="width: 100%; height: 100%; position: relative;">
      <div ref="pixiView" style="position: absolute; inset: 0;"></div>
      <div style="position: absolute; top: 12px; left: 14px; z-index: 30; pointer-events: none; max-width: 52%;">
        <div style="display: flex; flex-direction: column; gap: 6px; align-items: flex-start;">
          <div v-for="item in cycleCapacity.loopList"
               :key="'loop-' + item.loopNo"
               @mouseenter="handleLoopCardEnter(item)"
               @mouseleave="handleLoopCardLeave(item)"
               style="padding: 6px 10px; border-radius: 4px; background: rgba(11, 35, 58, 0.72); color: #fff; font-size: 12px; line-height: 1.4; white-space: nowrap; pointer-events: auto;">
            åœˆ{{ item.loopNo }} |
            ç«™ç‚¹: {{ item.stationCount || 0 }} |
            ä»»åŠ¡: {{ item.taskCount || 0 }} |
            æ‰¿è½½: {{ formatLoadPercent(item.currentLoad) }}
          </div>
        </div>
      </div>
      <div v-show="shelfTooltip.visible"
           :style="shelfTooltipStyle()">
        {{ shelfTooltip.text }}
@@ -83,7 +97,16 @@
      containerResizeObserver: null,
      timer: null,
      adjustLabelTimer: null,
      isSwitchingFloor: false
      isSwitchingFloor: false,
      cycleCapacity: {
        loopList: [],
        totalStationCount: 0,
        taskStationCount: 0,
        currentLoad: 0
      },
      hoverLoopNo: null,
      hoverLoopStationIdSet: new Set(),
      loopHighlightColor: 0xfff34d
    }
  },
    mounted() {
@@ -102,6 +125,7 @@
      this.getCrnInfo();
      this.getDualCrnInfo();
      this.getSiteInfo();
      this.getCycleCapacityInfo();
      this.getRgvInfo();
    }, 1000);
  },
@@ -314,6 +338,7 @@
    },
    changeFloor(lev) {
      this.currentLev = lev;
      this.clearLoopStationHighlight();
      this.isSwitchingFloor = true;
      this.hideShelfTooltip();
      this.hoveredShelfCell = null;
@@ -338,6 +363,7 @@
      this.getMap();
    },
    createMapData(map) {
      this.clearLoopStationHighlight();
      this.hideShelfTooltip();
      this.hoveredShelfCell = null;
      this.mapRowOffsets = [];
@@ -652,21 +678,21 @@
          sta.statusObj = null;
          if (sta.textObj.parent !== sta) { sta.addChild(sta.textObj); sta.textObj.position.set(sta.width / 2, sta.height / 2); }
        }
        let baseColor = 0xb8b8b8;
        if (status === "site-auto") {
          this.updateColor(sta, 0x78ff81);
          baseColor = 0x78ff81;
        } else if (status === "site-auto-run" || status === "site-auto-id" || status === "site-auto-run-id") {
          this.updateColor(sta, 0xfa51f6);
          baseColor = 0xfa51f6;
        } else if (status === "site-unauto") {
          this.updateColor(sta, 0xb8b8b8);
          baseColor = 0xb8b8b8;
        } else if (status === "machine-pakin") {
          this.updateColor(sta, 0x30bffc);
          baseColor = 0x30bffc;
        } else if (status === "machine-pakout") {
          this.updateColor(sta, 0x97b400);
          baseColor = 0x97b400;
        } else if (status === "site-run-block") {
          this.updateColor(sta, 0xe69138);
        } else {
          this.updateColor(sta, 0xb8b8b8);
          baseColor = 0xe69138;
        }
        this.setStationBaseColor(sta, baseColor);
      });
    },
    getCrnInfo() {
@@ -684,6 +710,38 @@
    getRgvInfo() {
      if (this.isSwitchingFloor) { return; }
      this.sendWs(JSON.stringify({ url: "/console/latest/data/rgv", data: {} }));
    },
    getCycleCapacityInfo() {
      if (this.isSwitchingFloor) { return; }
      this.sendWs(JSON.stringify({ url: "/console/latest/data/station/cycle/capacity", data: {} }));
    },
    setCycleCapacityInfo(res) {
      const payload = res && res.code === 200 ? res.data : null;
      if (res && res.code === 403) { parent.location.href = baseUrl + "/login"; return; }
      if (!payload) { return; }
      const loopList = Array.isArray(payload.loopList) ? payload.loopList : [];
      this.cycleCapacity = {
        loopList: loopList,
        totalStationCount: payload.totalStationCount || 0,
        taskStationCount: payload.taskStationCount || 0,
        currentLoad: typeof payload.currentLoad === 'number' ? payload.currentLoad : parseFloat(payload.currentLoad || 0)
      };
      if (this.hoverLoopNo != null) {
        const targetLoop = loopList.find(v => v && v.loopNo === this.hoverLoopNo);
        if (targetLoop) {
          this.hoverLoopStationIdSet = this.buildStationIdSet(targetLoop.stationIdList);
          this.applyLoopStationHighlight();
        } else {
          this.clearLoopStationHighlight();
        }
      }
    },
    formatLoadPercent(load) {
      let value = typeof load === 'number' ? load : parseFloat(load || 0);
      if (!isFinite(value)) { value = 0; }
      if (value < 0) { value = 0; }
      if (value > 1) { value = 1; }
      return (value * 100).toFixed(1) + "%";
    },
    setCrnInfo(res) {
      let crns = Array.isArray(res) ? res : (res && res.code === 200 ? res.data : null);
@@ -839,6 +897,7 @@
      if (this.wsReconnectTimer) { clearTimeout(this.wsReconnectTimer); this.wsReconnectTimer = null; }
      this.wsReconnectAttempts = 0;
      this.getMap(this.currentLev);
      this.getCycleCapacityInfo();
    },
    webSocketOnError(e) {
      this.scheduleReconnect();
@@ -853,6 +912,8 @@
        this.setDualCrnInfo(JSON.parse(result.data));
      } else if (result.url === "/console/latest/data/rgv") {
        this.setRgvInfo(JSON.parse(result.data));
      } else if (result.url === "/console/latest/data/station/cycle/capacity") {
        this.setCycleCapacityInfo(JSON.parse(result.data));
      } else if (typeof result.url === "string" && result.url.indexOf("/basMap/lev/") === 0) {
        this.setMap(JSON.parse(result.data));
      }
@@ -1200,7 +1261,11 @@
        text.position.set(sprite.width / 2, sprite.height / 2);
        sprite.addChild(text);
        sprite.textObj = text;
        if (siteId != null && siteId !== -1) { this.pixiStaMap.set(parseInt(siteId), sprite); }
        const stationIdInt = parseInt(siteId, 10);
        if (!isNaN(stationIdInt)) { this.pixiStaMap.set(stationIdInt, sprite); }
        sprite._stationId = isNaN(stationIdInt) ? null : stationIdInt;
        sprite._baseColor = 0x00ff7f;
        sprite._loopHighlighted = false;
        sprite.interactive = true;
        sprite.buttonMode = true;
        sprite.on('pointerdown', () => {
@@ -1401,6 +1466,74 @@
        return;
      }
      sprite.tint = color;
    },
    setStationBaseColor(sprite, color) {
      if (!sprite) { return; }
      sprite._baseColor = color;
      if (this.isStationInHoverLoop(sprite)) {
        this.applyHighlightColor(sprite);
      } else {
        this.updateColor(sprite, color);
        sprite._loopHighlighted = false;
      }
    },
    applyHighlightColor(sprite) {
      if (!sprite) { return; }
      this.updateColor(sprite, this.loopHighlightColor);
      sprite._loopHighlighted = true;
    },
    isStationInHoverLoop(sprite) {
      if (!sprite || sprite._stationId == null || !this.hoverLoopStationIdSet) { return false; }
      return this.hoverLoopStationIdSet.has(sprite._stationId);
    },
    buildStationIdSet(stationIdList) {
      const set = new Set();
      if (!Array.isArray(stationIdList)) { return set; }
      stationIdList.forEach((id) => {
        const v = parseInt(id, 10);
        if (!isNaN(v)) { set.add(v); }
      });
      return set;
    },
    applyLoopStationHighlight() {
      if (!this.pixiStaMap) { return; }
      this.pixiStaMap.forEach((sprite) => {
        if (!sprite) { return; }
        if (this.isStationInHoverLoop(sprite)) {
          this.applyHighlightColor(sprite);
        } else if (sprite._loopHighlighted) {
          const baseColor = (typeof sprite._baseColor === 'number') ? sprite._baseColor : 0xb8b8b8;
          this.updateColor(sprite, baseColor);
          sprite._loopHighlighted = false;
        }
      });
    },
    clearLoopStationHighlight() {
      if (this.pixiStaMap) {
        this.pixiStaMap.forEach((sprite) => {
          if (!sprite || !sprite._loopHighlighted) { return; }
          const baseColor = (typeof sprite._baseColor === 'number') ? sprite._baseColor : 0xb8b8b8;
          this.updateColor(sprite, baseColor);
          sprite._loopHighlighted = false;
        });
      }
      this.hoverLoopNo = null;
      this.hoverLoopStationIdSet = new Set();
    },
    handleLoopCardEnter(loopItem) {
      if (!loopItem) { return; }
      this.hoverLoopNo = loopItem.loopNo;
      this.hoverLoopStationIdSet = this.buildStationIdSet(loopItem.stationIdList);
      this.applyLoopStationHighlight();
    },
    handleLoopCardLeave(loopItem) {
      if (!loopItem) {
        this.clearLoopStationHighlight();
        return;
      }
      if (this.hoverLoopNo === loopItem.loopNo) {
        this.clearLoopStationHighlight();
      }
    },
    isJson(str) {
      try { JSON.parse(str); return true; } catch (e) { return false; }
@@ -1883,11 +2016,6 @@
    }
  }
});