自动化立体仓库 - WMS系统
chen.lin
4 天以前 10778ff6207c31641187acb487d4b67c0de59b24
src/main/java/com/zy/asrs/service/impl/MobileServiceImpl.java
@@ -25,6 +25,7 @@
import com.zy.common.model.enums.WorkNoType;
import com.zy.common.properties.AgvProperties;
import com.zy.common.service.CommonService;
import com.zy.common.utils.AgvUtils;
import com.zy.common.utils.HttpHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.formula.functions.T;
@@ -128,6 +129,9 @@
    @Resource
    private AgvProperties agvProperties;
    @Autowired
    private com.zy.asrs.task.handler.AgvHandler agvHandler;
    /**
     * 站点轮询计数器,用于平均分配站点
     * Key: 站点组标识(如 "east" 或 "west"),Value: 当前轮询索引
@@ -139,6 +143,12 @@
        int type = param.getType();
        String sourceSite = param.getSourceSite();
        String barcode = param.getBarcode();
        // 检查托盘码和暂存位编码是否相同
        if (barcode != null && sourceSite != null && barcode.trim().equals(sourceSite.trim())) {
            throw new CoolException("托盘码和暂存位编码不能相同");
        }
        int ioType;
        // 查询源站点(库位)信息,但不检查是否存在,允许下单成功
        // 站点不存在的检查将在定时任务(AgvHandler.callAgv)中进行
@@ -208,162 +218,58 @@
                throw new CoolException("入库类型错误,type:" + type);
        }
        // 条码存在agv任务
        int count = taskService.selectCount(new EntityWrapper<Task>().eq("barcode", barcode));
        int count = taskService.selectCount(new EntityWrapper<Task>().eq("barcode", barcode).eq("is_deleted", 0));
        if (count > 0) {
            throw new CoolException(barcode+ ":条码存在agv搬运任务!");
        }
        // 根据whs_type和库位编号前缀选择站点和机器人组
        // 如果库位不存在,使用默认逻辑(根据type判断),站点不存在的检查将在定时任务中进行
        // 根据whs_type确定机器人组(站点分配完全由定时任务处理)
        // 如果库位不存在,使用默认逻辑(根据type判断)
        Long whsType = locCache != null ? locCache.getWhsType() : null;
        String locNo = locCache != null ? locCache.getLocNo() : sourceSite;
        List<String> targetStations;
        String robotGroup;
        
        // 判断库位编号前缀:CA开头只做入库,WA开头才会被出库分配缓存区(从配置读取)
        String inboundOnlyPrefix = agvProperties.getLocationPrefix().getInboundOnly();
        String cacheAreaPrefix = agvProperties.getLocationPrefix().getCacheArea();
        boolean isCA = locNo != null && locNo.startsWith(inboundOnlyPrefix);
        boolean isWA = locNo != null && locNo.startsWith(cacheAreaPrefix);
        if (whsType != null && whsType.equals(agvProperties.getWhsTypeMapping().getInboundArea())) {
            // whs_type = 1: 入库区,使用东侧站点和Group-001
            targetStations = agvProperties.getEastStations();
            // whs_type = 1: 入库区,使用Group-001
            robotGroup = agvProperties.getRobotGroupEast();
            log.info("库位whs_type={},库位编号={}({}),使用入库区配置({}站点和{})",
                whsType, locNo, isCA ? "CA" : (isWA ? "WA" : "其他"),
                agvProperties.getEastDisplayName(), robotGroup);
            log.info("库位whs_type={},使用入库区配置({})", whsType, robotGroup);
        } else if (whsType != null && whsType.equals(agvProperties.getWhsTypeMapping().getCacheArea())) {
            // whs_type = 2: 缓存区,使用西侧站点和Group-002
            // 注意:如果有CA开头的入库,但是标记在西侧的(whs_type=2),也会分配到西侧的站点入库
            targetStations = agvProperties.getWestStations();
            // whs_type = 2: 缓存区,使用Group-002
            robotGroup = agvProperties.getRobotGroupWest();
            log.info("库位whs_type={},库位编号={}({}),使用缓存区配置({}站点和{})",
                whsType, locNo, isCA ? "CA" : (isWA ? "WA" : "其他"),
                agvProperties.getWestDisplayName(), robotGroup);
            log.info("库位whs_type={},使用缓存区配置({})", whsType, robotGroup);
        } else {
            // whs_type为空或其他值,根据type判断(兼容旧逻辑)
            // 如果库位不存在,也使用此逻辑
            if (type == 1) {
                targetStations = agvProperties.getEastStations();
                robotGroup = agvProperties.getRobotGroupEast();
            } else {
                targetStations = agvProperties.getWestStations();
                robotGroup = agvProperties.getRobotGroupWest();
            }
            if (locCache == null) {
                log.warn("源站点(库位){}不存在,使用type={}的默认逻辑,站点检查将在定时任务中进行", sourceSite, type);
                log.warn("源站点(库位){}不存在,使用type={}的默认逻辑,机器人组:{},站点分配将在定时任务中进行", sourceSite, type, robotGroup);
            } else {
                log.warn("库位whs_type={}未配置或不在映射范围内,使用type={}的默认逻辑", whsType, type);
                log.warn("库位whs_type={}未配置或不在映射范围内,使用type={}的默认逻辑,机器人组:{}", whsType, type, robotGroup);
            }
        }
        
        // 将站点字符串列表转换为整数列表
        List<Integer> siteIntList = targetStations.stream()
                .map(Integer::parseInt)
                .collect(Collectors.toList());
        // 判断能入站点(in_enable="Y"表示能入),排除dev_no=0的无效站点
        // 注意:不在此处检查站点是否存在或可用,允许下单成功
        // 站点检查和分配将在定时任务(AgvHandler.callAgv)中进行
        List<Integer> sites = basDevpMapper.selectList(
                new EntityWrapper<BasDevp>()
                        .eq("in_enable", "Y") // in_enable是能入
                        .in("dev_no", siteIntList)
                        .ne("dev_no", 0) // 排除dev_no=0的无效站点
        ).stream()
                .map(BasDevp::getDevNo)
                .filter(devNo -> devNo != null && devNo != 0) // 再次过滤,确保不为null或0
                .collect(Collectors.toList());
        // 获取没有出库任务的站点
        List<Integer> canInSites = sites.isEmpty() ? new ArrayList<>() : basDevpMapper.getCanInSites(sites);
        // 寻找入库任务最少的站点(且必须in_enable="Y"能入 和 canining="Y"可入),排除dev_no=0的无效站点
        // 注意:不在此处检查未完成的AGV任务,允许PDA持续申请下单
        // 未完成任务的检查将在发送AGV请求时进行(AgvHandler.callAgv)
        List<BasDevp> devList = canInSites.isEmpty() ? new ArrayList<>() : basDevpMapper.selectList(new EntityWrapper<BasDevp>()
                .in("dev_no", canInSites)
                .eq("in_enable", "Y") // in_enable是能入
                .eq("canining", "Y") // canining是可入
                .ne("dev_no", 0) // 排除dev_no=0的无效站点
        ).stream()
                .filter(dev -> dev.getDevNo() != null && dev.getDevNo() != 0) // 再次过滤,确保不为null或0
                .collect(Collectors.toList());
        // 选择站点(如果可入站点为空,则不在创建任务时分配站点,由定时任务分配)
        Integer endSite = null;
        if (!devList.isEmpty()) {
            // 入库任务数排序
            devList.sort(Comparator.comparing(BasDevp::getInQty));
            // 选择站点
            BasDevp basDevp;
            // 获取最少任务数
            int minInQty = devList.get(0).getInQty();
            // 筛选出任务数最少的站点列表
            List<BasDevp> minTaskSites = devList.stream()
                    .filter(dev -> dev.getInQty() == minInQty)
                    .collect(Collectors.toList());
            // 根据配置选择分配策略
            String strategy = agvProperties.getSiteAllocation().getStrategy();
            boolean enableRoundRobin = agvProperties.getSiteAllocation().isEnableRoundRobin();
            if (minTaskSites.size() > 1 && enableRoundRobin && "round-robin".equals(strategy)) {
                // 轮询分配:当多个站点任务数相同时,使用轮询
                String groupKey = (whsType != null && whsType.equals(agvProperties.getWhsTypeMapping().getInboundArea()))
                        ? "east" : "west";
                AtomicInteger counter = siteRoundRobinCounters.computeIfAbsent(groupKey, k -> new AtomicInteger(0));
                int index = counter.getAndIncrement() % minTaskSites.size();
                basDevp = minTaskSites.get(index);
                log.info("使用轮询分配策略,站点组:{},轮询索引:{},选中站点:{}", groupKey, index, basDevp.getDevNo());
            } else if (minTaskSites.size() > 1 && enableRoundRobin && "random".equals(strategy)) {
                // 随机分配
                Random random = new Random();
                int index = random.nextInt(minTaskSites.size());
                basDevp = minTaskSites.get(index);
                log.info("使用随机分配策略,选中站点:{}", basDevp.getDevNo());
            } else {
                // 默认:选择第一个(任务最少的)
                basDevp = devList.get(0);
                if (minTaskSites.size() > 1) {
                    log.info("多个站点任务数相同({}),但未启用轮询,选择第一个站点:{}", minInQty, basDevp.getDevNo());
                }
            }
            endSite = basDevp.getDevNo();
            // 检查站点是否有效(不能为0或null)
            if (endSite == null || endSite == 0) {
                log.error("分配的站点无效(dev_no={}),不分配站点,将在定时任务中分配", endSite);
                endSite = null; // 设置为null,由定时任务分配
            } else {
                // 入库暂存+1
                basDevpMapper.incrementInQty(endSite);
            }
        } else {
            // 没有可入站点,记录日志但不阻止下单,站点分配将在定时任务中处理
            String groupName = (whsType != null && whsType.equals(agvProperties.getWhsTypeMapping().getInboundArea()))
                    ? agvProperties.getEastDisplayName() : agvProperties.getWestDisplayName();
//            log.warn("{}可用站点({})中没有可入站点(in_enable='Y'且canining='Y'),暂不分配站点,将在定时任务中分配", groupName, canInSites);
        }
        // 站点分配完全由定时任务处理,此处不分配站点,只创建任务
        log.info("创建AGV任务,站点分配将在定时任务中处理,机器人组:{}", robotGroup);
        // 获取工作号
        int workNo = commonService.getWorkNo(WorkNoType.PICK.type);
        // 生成AGV工作号
        String agvWrkNo = AgvUtils.generateAgvWrkNo(workNo);
        // 保存工作档
        Task task = new Task();
        Date now = new Date();
        task.setWrkNo(workNo)
                .setAgvWrkNo(agvWrkNo) // 设置AGV工作号
                .setIoTime(now)
                .setWrkSts(7L) // 工作状态:11.生成出库ID
                .setIoType(ioType) // 入出库状态: 1.入库
                .setTaskType("agv")
                .setIoPri(10D)
                .setStaNo(endSite != null ? String.valueOf(endSite) : null) // 如果分配了站点则设置,否则为null,由定时任务分配
                .setStaNo(null) // 站点分配完全由定时任务处理
                .setSourceStaNo(sourceSite) // 设置源站点
                .setInvWh(robotGroup) // 根据whs_type设置机器人组
                .setFullPlt(ioType == 10 ? "N" : "Y")// 空托入库(ioType=10)设置为N,其他入库设置为Y(满板)
@@ -1455,23 +1361,54 @@
     */
    @Override
    public R callAgvMove(AgvCallParams params, Long userId) {
        if (Objects.isNull(params.getTarSite())) {
            throw new CoolException("目标参数不能为空!!");
        }
        if (Objects.isNull(params.getOrgSite())) {
            throw new CoolException("源站点不能为空!!");
        }
        LocCache locCache = locCacheService.selectOne(new EntityWrapper<LocCache>()
                .eq("frozen", 0)
                .eq("loc_sts", LocStsType.LOC_STS_TYPE_O.type)
                .eq("loc_no", params.getTarSite())
                .orderAsc(Arrays.asList("loc_no"))
                .last("OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY"));
        if (Objects.isNull(locCache)) {
            throw new CoolException("请检查目标库位是否闲置中!!");
        if (Objects.isNull(params.getBarcode())) {
            throw new CoolException("托盘码不能为空!!");
        }
        generateAgvTask("agv", locCache, params.getOrgSite(), params.getBarcode(), userId);
        String callType = params.getCallType();
        if (callType == null || callType.isEmpty()) {
            callType = "manual"; // 默认手动输入
        }
        if ("manual".equals(callType)) {
            // 手动输入:需要目标站点
            if (Objects.isNull(params.getTarSite())) {
                throw new CoolException("手动输入模式下,目标站点不能为空!!");
            }
            LocCache locCache = locCacheService.selectOne(new EntityWrapper<LocCache>()
                    .eq("frozen", 0)
                    .eq("loc_sts", LocStsType.LOC_STS_TYPE_O.type)
                    .eq("loc_no", params.getTarSite())
                    .orderAsc(Arrays.asList("loc_no"))
                    .last("OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY"));
            if (Objects.isNull(locCache)) {
                throw new CoolException("请检查目标库位是否闲置中!!");
            }
            generateAgvTask("agv", locCache, params.getOrgSite(), params.getBarcode(), userId);
        } else if ("outbound".equals(callType)) {
            // 起点+出库:自动分配站点和缓存位
            generateOutboundAgvTask(params.getOrgSite(), params.getBarcode(), userId);
        } else if ("inbound".equals(callType)) {
            // 起点+入库:自动分配站点,库位手动输入
            if (Objects.isNull(params.getTarLoc())) {
                throw new CoolException("入库模式下,目标库位不能为空!!");
            }
            LocCache locCache = locCacheService.selectOne(new EntityWrapper<LocCache>()
                    .eq("frozen", 0)
                    .eq("loc_sts", LocStsType.LOC_STS_TYPE_O.type)
                    .eq("loc_no", params.getTarLoc())
                    .orderAsc(Arrays.asList("loc_no"))
                    .last("OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY"));
            if (Objects.isNull(locCache)) {
                throw new CoolException("请检查目标库位是否闲置中!!");
            }
            generateInboundAgvTask(locCache, params.getOrgSite(), params.getBarcode(), userId);
        } else {
            throw new CoolException("不支持的呼叫类型:" + callType);
        }
        return R.ok();
    }
@@ -1542,9 +1479,12 @@
    public void generateOutTask(BasStation station, LocCache loc, Long userId) {
        // 获取工作号
        int workNo = commonService.getWorkNo(WorkNoType.PICK.type);
        // 生成AGV工作号
        String agvWrkNo = AgvUtils.generateAgvWrkNo(workNo);
        // 保存工作档
        Task task = new Task();
        task.setWrkNo(workNo)
                .setAgvWrkNo(agvWrkNo)
                .setIoTime(new Date())
                .setWrkSts(11L) // 工作状态:11.生成出库ID
                .setIoType(101) // 入出库状态: 11.库格移载
@@ -1629,15 +1569,20 @@
        }
        // 获取工作号
        int workNo = commonService.getWorkNo(WorkNoType.PICK.type);
        // 生成AGV工作号
        String agvWrkNo = AgvUtils.generateAgvWrkNo(workNo);
        // 保存工作档
        Task task = new Task();
        Date now = new Date();
        task.setWrkNo(workNo)
                .setIoTime(new Date())
                .setWrkSts(1L) // 工作状态:11.生成出库ID
                .setAgvWrkNo(agvWrkNo) // 设置AGV工作号
                .setIoTime(now)
                .setWrkSts(7L) // 工作状态:7.待呼叫AGV(与自动创建保持一致,由定时任务分配站点并呼叫)
                .setIoType(1) // 入出库状态: 11.库格移载
                .setTaskType("agv")
                .setIoPri(10D)
                .setLocNo(loc.getLocNo()) // 目标库位
                .setStaNo(null) // 站点分配由定时任务处理(与自动创建保持一致)
                .setFullPlt("Y") // 满板:Y
                .setPicking("N") // 拣料
                .setExitMk("N")// 退出
@@ -1646,9 +1591,9 @@
                .setBarcode(barcode)// 托盘码
                .setLinkMis("N")
                .setAppeUser(userId)
                .setAppeTime(new Date())
                .setAppeTime(now)
                .setModiUser(userId)
                .setModiTime(new Date());
                .setModiTime(now);
        if (!taskService.insert(task)) {
            throw new CoolException("保存工作档失败");
        }
@@ -1689,4 +1634,448 @@
            throw new CoolException("移转失败,目标库位状态:" + loc.getLocSts$());
        }
    }
    /**
     * 生成出库AGV任务(起点+出库,自动分配站点和缓存位)
     * @param orgSite 源站点
     * @param barcode 托盘码
     * @param userId 用户ID
     */
    @Transactional(rollbackFor = Exception.class)
    public void generateOutboundAgvTask(String orgSite, String barcode, Long userId) {
        // 根据源站点判断是东侧还是西侧,确定缓存区站点和whs_type
        List<String> eastStations = agvProperties.getEastStations();
        List<String> westStations = agvProperties.getWestStations();
        List<String> cacheStations;
        Long targetWhsType;
        String robotGroup;
        String cacheAreaPrefix = agvProperties.getLocationPrefix().getCacheArea();
        boolean isEmptyPallet = false; // 默认满板出库,可根据需要调整
        if (eastStations.contains(orgSite)) {
            // 东侧出库站点,查找东侧WA库位(whs_type=1)
            cacheStations = agvProperties.getEastStations();
            robotGroup = agvProperties.getRobotGroupEast();
            targetWhsType = agvProperties.getWhsTypeMapping().getInboundArea(); // whs_type=1
        } else if (westStations.contains(orgSite)) {
            // 西侧出库站点,查找西侧WA库位(whs_type=2)
            cacheStations = agvProperties.getWestStations();
            robotGroup = agvProperties.getRobotGroupWest();
            targetWhsType = agvProperties.getWhsTypeMapping().getCacheArea(); // whs_type=2
        } else {
            // 默认使用西侧
            cacheStations = agvProperties.getWestStations();
            robotGroup = agvProperties.getRobotGroupWest();
            targetWhsType = agvProperties.getWhsTypeMapping().getCacheArea();
            log.warn("源站点{}不在配置的站点列表中,使用默认西侧配置", orgSite);
        }
        // 自动分配缓存位(使用优先级分配逻辑)
        LocCache locCache = allocateCacheLocationByPriority(targetWhsType, cacheAreaPrefix, isEmptyPallet);
        if (Objects.isNull(locCache)) {
            throw new CoolException("暂无满足需求的缓存位!");
        }
        // 自动分配站点(使用出库到缓存区的分配策略)
        String allocatedSite = allocateCacheStationForOutbound(cacheStations, 101);
        if (allocatedSite == null) {
            throw new CoolException("无法分配缓存区站点,所有站点都在使用中!");
        }
        // 获取工作号
        int workNo = commonService.getWorkNo(WorkNoType.PICK.type);
        // 生成AGV工作号
        String agvWrkNo = AgvUtils.generateAgvWrkNo(workNo);
        // 保存工作档
        Task task = new Task();
        Date now = new Date();
        task.setWrkNo(workNo)
                .setAgvWrkNo(agvWrkNo)
                .setIoTime(now)
                .setWrkSts(7L) // 工作状态:7.待呼叫AGV(站点已分配)
                .setIoType(101) // 入出库状态:101.全板出库
                .setTaskType("agv")
                .setIoPri(10D)
                .setStaNo(allocatedSite) // 站点已自动分配
                .setLocNo(locCache.getLocNo()) // 目标缓存位
                .setFullPlt("Y") // 满板:Y
                .setPicking("N") // 拣料
                .setExitMk("N")// 退出
                .setSourceStaNo(orgSite) // 源站点
                .setSourceLocNo(locCache.getLocNo()) // 源库位(缓存位)
                .setEmptyMk(locCache.getLocSts().equals("D") ? "Y" : "N")// 空板
                .setBarcode(barcode)// 托盘码
                .setLinkMis("N")
                .setInvWh(robotGroup) // 根据源站点设置机器人组
                .setAppeUser(userId)
                .setAppeTime(now)
                .setModiUser(userId)
                .setModiTime(now);
        if (!taskService.insert(task)) {
            throw new CoolException("保存工作档失败");
        }
        // 更新缓存位状态:O(闲置)-> R(出库预约)
        locCache.setLocSts(LocStsType.LOC_STS_TYPE_R.type);
        locCache.setModiUser(userId);
        locCache.setModiTime(now);
        if (!locCacheService.updateById(locCache)) {
            throw new CoolException("更新缓存位状态失败");
        }
    }
    /**
     * 生成入库AGV任务(起点+入库,自动分配站点,库位手动指定)
     * @param locCache 目标库位
     * @param orgSite 源站点
     * @param barcode 托盘码
     * @param userId 用户ID
     */
    @Transactional(rollbackFor = Exception.class)
    public void generateInboundAgvTask(LocCache locCache, String orgSite, String barcode, Long userId) {
        // 判断是否是空托入库:库位状态为"D"(空桶/空栈板)表示空托入库
        boolean isEmptyPallet = LocStsType.LOC_STS_TYPE_D.type.equals(locCache.getLocSts());
        Integer ioType = isEmptyPallet ? 10 : 1; // 10=空托入库,1=实托入库
        // 只有实托入库才需要组托信息
        List<WaitPakin> pakins = null;
        if (!isEmptyPallet) {
            // 实托入库:检查组托信息是否存在
            pakins = waitPakinService.selectList(new EntityWrapper<WaitPakin>().eq("zpallet", barcode));
            if (Objects.isNull(pakins) || pakins.isEmpty()) {
                throw new CoolException("组托信息不存在!!");
            }
        }
        // 获取工作号
        int workNo = commonService.getWorkNo(WorkNoType.PICK.type);
        // 生成AGV工作号
        String agvWrkNo = AgvUtils.generateAgvWrkNo(workNo);
        // 保存工作档
        Task task = new Task();
        Date now = new Date();
        // 根据库位whs_type确定机器人组
        Long whsType = locCache.getWhsType();
        String robotGroup;
        if (whsType != null && whsType.equals(agvProperties.getWhsTypeMapping().getInboundArea())) {
            robotGroup = agvProperties.getRobotGroupEast();
        } else if (whsType != null && whsType.equals(agvProperties.getWhsTypeMapping().getCacheArea())) {
            robotGroup = agvProperties.getRobotGroupWest();
        } else {
            robotGroup = agvProperties.getRobotGroupEast(); // 默认东侧
        }
        task.setWrkNo(workNo)
                .setAgvWrkNo(agvWrkNo) // 设置AGV工作号
                .setIoTime(now)
                .setWrkSts(7L) // 工作状态:7.待呼叫AGV(站点将自动分配)
                .setIoType(ioType) // 入出库状态:10=空托入库,1=实托入库
                .setTaskType("agv")
                .setIoPri(10D)
                .setLocNo(locCache.getLocNo()) // 目标库位
                .setStaNo(null) // 站点将自动分配
                .setFullPlt(isEmptyPallet ? "N" : "Y") // 空托入库设置为N,实托入库设置为Y
                .setPicking("N") // 拣料
                .setExitMk("N")// 退出
                .setSourceStaNo(orgSite) // 源站点
                .setSourceLocNo(orgSite) // 源库位(使用源站点)
                .setEmptyMk(isEmptyPallet ? "Y" : "N")// 空板:空托入库为Y,实托入库为N
                .setBarcode(barcode)// 托盘码
                .setLinkMis("N")
                .setInvWh(robotGroup) // 根据whs_type设置机器人组
                .setAppeUser(userId)
                .setAppeTime(now)
                .setModiUser(userId)
                .setModiTime(now);
        if (!taskService.insert(task)) {
            throw new CoolException("保存工作档失败");
        }
        // 立即分配站点(跟随入库逻辑)
        String errorMsg = agvHandler.allocateSiteForTask(task);
        if (errorMsg != null) {
            throw new CoolException("分配站点失败:" + errorMsg);
        }
        // 重新查询任务以获取分配后的站点
        task = taskService.selectById(task.getId());
        if (task.getStaNo() == null || task.getStaNo().isEmpty() || task.getStaNo().equals("0")) {
            throw new CoolException("站点分配失败,无法继续!");
        }
        // 只有实托入库才需要保存工作档明细
        if (!isEmptyPallet && pakins != null && !pakins.isEmpty()) {
            List<TaskDetl> taskDetls = new ArrayList<>();
            pakins.forEach(pakin -> {
                TaskDetl wrkDetl = new TaskDetl();
                BeanUtils.copyProperties(pakin, wrkDetl);
                wrkDetl.setWrkNo(workNo)
                        .setIoTime(new Date())
                        .setOrderNo(pakin.getOrderNo())
                        .setAnfme(pakin.getAnfme())
                        .setZpallet(pakin.getZpallet())
                        .setBatch(pakin.getBatch())
                        .setMatnr(pakin.getMatnr())
                        .setMaktx(pakin.getMaktx())
                        .setAppeUser(userId)
                        .setUnit(pakin.getUnit())
                        .setModel(pakin.getModel())
                        .setAppeTime(new Date())
                        .setModiUser(userId);
                taskDetls.add(wrkDetl);
            });
            if (!taskDetlService.insertBatch(taskDetls)) {
                throw new CoolException("保存工作档明细失败");
            }
        }
        // 修改目标库位状态:O(闲置)-> S(入库预约)
        if (locCache.getLocSts().equals(LocStsType.LOC_STS_TYPE_O.type)) {
            locCache.setLocSts(LocStsType.LOC_STS_TYPE_S.type);
            locCache.setModiTime(now);
            locCache.setModiUser(userId);
            if (!locCacheService.updateById(locCache)) {
                throw new CoolException("更新目标库位状态失败");
            }
        } else {
            throw new CoolException("移转失败,目标库位状态:" + locCache.getLocSts());
        }
    }
    /**
     * 按优先级分配缓存库位(跟随出库逻辑)
     * @param whsType 库区类型
     * @param cacheAreaPrefix 缓存区库位前缀(如"WA")
     * @param isEmptyPallet 是否空托
     * @return 分配的缓存库位,如果无法分配则返回null
     */
    private LocCache allocateCacheLocationByPriority(Long whsType, String cacheAreaPrefix, boolean isEmptyPallet) {
        // 查询所有符合条件的空库位
        List<LocCache> allLocations = locCacheService.selectList(new EntityWrapper<LocCache>()
                .eq("whs_type", whsType)
                .like("loc_no", cacheAreaPrefix + "%")
                .eq("frozen", 0)
                .eq("loc_sts", LocStsType.LOC_STS_TYPE_O.type) // O.闲置
                .ne("full_plt", isEmptyPallet ? "Y" : "N") // 空托不选满板库位,满托不选空板库位
        );
        if (allLocations == null || allLocations.isEmpty()) {
            return null;
        }
        // 按row1分组
        Map<Integer, List<LocCache>> locationsByRow = allLocations.stream()
                .filter(loc -> loc.getRow1() != null)
                .collect(Collectors.groupingBy(LocCache::getRow1));
        if (locationsByRow.isEmpty()) {
            return null;
        }
        // 优先级1:分配第三列(bay1=3),且该排的1、2、3列都是空的
        for (Map.Entry<Integer, List<LocCache>> entry : locationsByRow.entrySet()) {
            Integer row = entry.getKey();
            List<LocCache> rowLocs = entry.getValue();
            // 检查该排的1、2、3列是否都有空库位
            boolean hasBay1 = rowLocs.stream().anyMatch(loc -> loc.getBay1() != null && loc.getBay1() == 1);
            boolean hasBay2 = rowLocs.stream().anyMatch(loc -> loc.getBay1() != null && loc.getBay1() == 2);
            boolean hasBay3 = rowLocs.stream().anyMatch(loc -> loc.getBay1() != null && loc.getBay1() == 3);
            if (hasBay1 && hasBay2 && hasBay3) {
                // 该排的1、2、3列都是空的,分配第三列
                List<LocCache> bay3Locs = rowLocs.stream()
                        .filter(loc -> loc.getBay1() != null && loc.getBay1() == 3)
                        .sorted(Comparator.comparing(loc -> loc.getLev1() != null ? loc.getLev1() : 0))
                        .collect(Collectors.toList());
                if (!bay3Locs.isEmpty()) {
                    log.debug("优先级1:分配排{}的第三列,库位:{}", row, bay3Locs.get(0).getLocNo());
                    return bay3Locs.get(0);
                }
            }
        }
        // 优先级2:分配第二列(bay1=2),且该排的1、2列都是空的
        for (Map.Entry<Integer, List<LocCache>> entry : locationsByRow.entrySet()) {
            Integer row = entry.getKey();
            List<LocCache> rowLocs = entry.getValue();
            boolean hasBay1 = rowLocs.stream().anyMatch(loc -> loc.getBay1() != null && loc.getBay1() == 1);
            boolean hasBay2 = rowLocs.stream().anyMatch(loc -> loc.getBay1() != null && loc.getBay1() == 2);
            if (hasBay1 && hasBay2) {
                List<LocCache> bay2Locs = rowLocs.stream()
                        .filter(loc -> loc.getBay1() != null && loc.getBay1() == 2)
                        .sorted(Comparator.comparing(loc -> loc.getLev1() != null ? loc.getLev1() : 0))
                        .collect(Collectors.toList());
                if (!bay2Locs.isEmpty()) {
                    log.debug("优先级2:分配排{}的第二列,库位:{}", row, bay2Locs.get(0).getLocNo());
                    return bay2Locs.get(0);
                }
            }
        }
        // 优先级3:分配第一列(bay1=1)
        for (Map.Entry<Integer, List<LocCache>> entry : locationsByRow.entrySet()) {
            Integer row = entry.getKey();
            List<LocCache> rowLocs = entry.getValue();
            List<LocCache> bay1Locs = rowLocs.stream()
                    .filter(loc -> loc.getBay1() != null && loc.getBay1() == 1)
                    .sorted(Comparator.comparing(loc -> loc.getLev1() != null ? loc.getLev1() : 0))
                    .collect(Collectors.toList());
            if (!bay1Locs.isEmpty()) {
                log.debug("优先级3:分配排{}的第一列,库位:{}", row, bay1Locs.get(0).getLocNo());
                return bay1Locs.get(0);
            }
        }
        // 优先级4:分配第二列(不要求该排的1、2列都是空的)
        for (Map.Entry<Integer, List<LocCache>> entry : locationsByRow.entrySet()) {
            Integer row = entry.getKey();
            List<LocCache> rowLocs = entry.getValue();
            List<LocCache> bay2Locs = rowLocs.stream()
                    .filter(loc -> loc.getBay1() != null && loc.getBay1() == 2)
                    .sorted(Comparator.comparing(loc -> loc.getLev1() != null ? loc.getLev1() : 0))
                    .collect(Collectors.toList());
            if (!bay2Locs.isEmpty()) {
                log.debug("优先级4:分配排{}的第二列,库位:{}", row, bay2Locs.get(0).getLocNo());
                return bay2Locs.get(0);
            }
        }
        // 优先级5:分配第三列(不要求该排的1、2、3列都是空的)
        for (Map.Entry<Integer, List<LocCache>> entry : locationsByRow.entrySet()) {
            Integer row = entry.getKey();
            List<LocCache> rowLocs = entry.getValue();
            List<LocCache> bay3Locs = rowLocs.stream()
                    .filter(loc -> loc.getBay1() != null && loc.getBay1() == 3)
                    .sorted(Comparator.comparing(loc -> loc.getLev1() != null ? loc.getLev1() : 0))
                    .collect(Collectors.toList());
            if (!bay3Locs.isEmpty()) {
                log.debug("优先级5:分配排{}的第三列,库位:{}", row, bay3Locs.get(0).getLocNo());
                return bay3Locs.get(0);
            }
        }
        return null;
    }
    /**
     * 为出库到缓存区的任务分配站点(跟随出库逻辑)
     * @param cacheStations 缓存区站点列表
     * @param ioType 任务类型(101=全板出库,110=空板出库)
     * @return 分配的站点编号,如果无法分配则返回null
     */
    private String allocateCacheStationForOutbound(List<String> cacheStations, Integer ioType) {
        if (cacheStations == null || cacheStations.isEmpty()) {
            log.warn("缓存区站点列表为空,无法分配站点");
            return null;
        }
        // 将站点字符串列表转换为整数列表
        List<Integer> siteIntList = cacheStations.stream()
                .map(Integer::parseInt)
                .collect(Collectors.toList());
        // 查询所有缓存区站点的设备信息(包含任务数)
        List<BasDevp> devList = basDevpMapper.selectList(new EntityWrapper<BasDevp>()
                .in("dev_no", siteIntList)
        );
        if (devList.isEmpty()) {
            log.warn("缓存区站点{}在设备表中不存在", cacheStations);
            return cacheStations.get(0); // 降级:返回第一个站点
        }
        // 按入库任务数排序(出库到缓存区也使用in_qty字段)
        devList.sort(Comparator.comparing(BasDevp::getInQty));
        // 获取最少任务数
        int minInQty = devList.get(0).getInQty();
        // 筛选出任务数最少的站点列表
        List<BasDevp> minTaskSites = devList.stream()
                .filter(dev -> dev.getInQty() == minInQty)
                .collect(Collectors.toList());
        // 根据配置选择分配策略
        String strategy = agvProperties.getSiteAllocation().getStrategy();
        boolean enableRoundRobin = agvProperties.getSiteAllocation().isEnableRoundRobin();
        List<BasDevp> orderedSites = new ArrayList<>();
        String groupKey = "west"; // 缓存区使用西侧
        if (minTaskSites.size() > 1 && enableRoundRobin && "round-robin".equals(strategy)) {
            // 轮询分配
            AtomicInteger counter = siteRoundRobinCounters.computeIfAbsent(groupKey, k -> new AtomicInteger(0));
            int startIndex = counter.get() % minTaskSites.size();
            orderedSites.addAll(minTaskSites.subList(startIndex, minTaskSites.size()));
            orderedSites.addAll(minTaskSites.subList(0, startIndex));
            orderedSites.addAll(devList.stream()
                    .filter(dev -> dev.getInQty() > minInQty)
                    .collect(Collectors.toList()));
            counter.getAndIncrement();
            log.debug("使用轮询分配策略,站点组:{},轮询起始索引:{}", groupKey, startIndex);
        } else if (minTaskSites.size() > 1 && enableRoundRobin && "random".equals(strategy)) {
            // 随机分配
            List<BasDevp> shuffledMinSites = new ArrayList<>(minTaskSites);
            Collections.shuffle(shuffledMinSites);
            orderedSites.addAll(shuffledMinSites);
            orderedSites.addAll(devList.stream()
                    .filter(dev -> dev.getInQty() > minInQty)
                    .collect(Collectors.toList()));
            log.debug("使用随机分配策略");
        } else {
            // 默认:按入库任务数排序
            orderedSites = devList;
        }
        // 依次检查每个站点是否在搬运,找到第一个空闲站点就分配
        String selectedSite = null;
        List<Integer> checkIoTypes = Arrays.asList(101, 110); // 出库到缓存区的任务类型
        for (BasDevp dev : orderedSites) {
            String staNo = String.valueOf(dev.getDevNo());
            // 检查该站点是否有正在搬运的同类型任务
            List<Task> transportingTasks = taskService.selectList(
                new EntityWrapper<Task>()
                    .eq("sta_no", staNo)
                    .eq("task_type", "agv")
                    .eq("wrk_sts", 8L) // 只检查正在搬运状态的任务
                    .in("io_type", checkIoTypes)
                    .eq("is_deleted", 0)
            );
            if (!transportingTasks.isEmpty()) {
                log.debug("缓存区站点{}有{}个正在搬运的出库AGV任务,检查下一个站点",
                    staNo, transportingTasks.size());
                continue; // 该站点正在搬运,检查下一个站点
            }
            // 找到第一个空闲站点,分配
            selectedSite = staNo;
            log.info("出库到缓存区任务按规则应分配到站点{},该站点空闲,分配成功", staNo);
            break;
        }
        // 如果所有站点都在搬运,则不分配站点
        if (selectedSite == null) {
            return null;
        }
        // 更新站点任务数(出库到缓存区也使用in_qty字段)
        basDevpMapper.incrementInQty(Integer.parseInt(selectedSite));
        return selectedSite;
    }
}