自动化立体仓库 - WMS系统
zwl
22 小时以前 a1b99c6eb0e36c36f7696eff9bc23ecf57206661
WCS申请任务重新分配入库位
8个文件已添加
5个文件已修改
968 ■■■■■ 已修改文件
src/main/java/com/zy/api/controller/WcsApiController.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/api/controller/params/ReassignLocParams.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/api/service/WcsApiService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/api/service/impl/WcsApiServiceImpl.java 228 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/task/AutoFrontLocMoveScheduler.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/task/handler/AutoFrontLocMoveHandler.java 238 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/task/support/AutoFrontLocMoveConfigResolver.java 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/task/support/AutoFrontLocMoveSettings.java 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/common/config/ControllerResAdvice.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/common/service/CommonService.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260407_auto_front_loc_move_config.sql 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/test/java/com/zy/asrs/task/handler/AutoFrontLocMoveHandlerTest.java 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/test/java/com/zy/asrs/task/support/AutoFrontLocMoveConfigResolverTest.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/api/controller/WcsApiController.java
@@ -4,6 +4,7 @@
import com.baomidou.mybatisplus.mapper.EntityWrapper;
import com.core.annotations.ManagerAuth;
import com.core.common.R;
import com.zy.api.controller.params.ReassignLocParams;
import com.zy.api.controller.params.ReceviceTaskParams;
import com.zy.api.controller.params.WorkTaskParams;
import com.zy.api.service.WcsApiService;
@@ -77,4 +78,15 @@
        return R.ok().add(waitPakins);
    }
    @ApiOperation("WCS申请任务重新分配入库位")
    @PostMapping("/openapi/reassign/loc")
    public R reassignInboundLoc(@RequestBody ReassignLocParams params, HttpServletRequest request) {
        if (Objects.isNull(params)) {
            return R.error("参数不能为空!!");
        }
        log.info("[reassignInboundLoc] request={}", JSON.toJSONString(params));
        request.setAttribute("cache", params);
        return wcsApiService.reassignInboundLoc(params);
    }
}
src/main/java/com/zy/api/controller/params/ReassignLocParams.java
New file
@@ -0,0 +1,21 @@
package com.zy.api.controller.params;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
@Data
@ApiModel(value = "ReassignLocParams", description = "wcs reassign inbound loc params")
public class ReassignLocParams implements Serializable {
    private static final long serialVersionUID = 1L;
    @ApiModelProperty("wms task no")
    private String taskNo;
    @ApiModelProperty("available rows, reserved for interface compatibility only")
    private List<Integer> row;
}
src/main/java/com/zy/api/service/WcsApiService.java
@@ -1,6 +1,7 @@
package com.zy.api.service;
import com.core.common.R;
import com.zy.api.controller.params.ReassignLocParams;
import com.zy.api.controller.params.ReceviceTaskParams;
import com.zy.api.controller.params.StopOutTaskParams;
import com.zy.api.controller.params.WorkTaskParams;
@@ -54,6 +55,13 @@
    R syncDeviceStatusFromWcs(boolean logOnFailure);
    /**
     * WCS 申请任务重新分配入库位
     * @param params
     * @return
     */
    R reassignInboundLoc(ReassignLocParams params);
    /**
     * batch pause out tasks
     * @param params
     * @return
src/main/java/com/zy/api/service/impl/WcsApiServiceImpl.java
@@ -7,6 +7,7 @@
import com.core.common.Cools;
import com.core.common.R;
import com.core.exception.CoolException;
import com.zy.api.controller.params.ReassignLocParams;
import com.zy.api.controller.params.ReceviceTaskParams;
import com.zy.api.controller.params.StopOutTaskParams;
import com.zy.api.controller.params.WorkTaskParams;
@@ -18,6 +19,8 @@
import com.zy.asrs.service.*;
import com.zy.asrs.utils.Utils;
import com.zy.common.constant.MesConstant;
import com.zy.common.model.LocTypeDto;
import com.zy.common.model.StartupDto;
import com.zy.common.service.CommonService;
import com.zy.common.utils.HttpHandler;
import lombok.extern.slf4j.Slf4j;
@@ -28,6 +31,7 @@
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@@ -90,6 +94,8 @@
    private BasCrnpService basCrnpService;
    @Autowired
    private ApiLogService apiLogService;
    @Autowired
    private RowLastnoService rowLastnoService;
    /**
@@ -607,6 +613,62 @@
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public R reassignInboundLoc(ReassignLocParams params) {
        if (params == null) {
            return R.error("参数不能为空!!");
        }
        if (Cools.isEmpty(params.getTaskNo())) {
            return R.error("任务号不能为空!!");
        }
        WrkMast wrkMast = wrkMastService.selectOne(new EntityWrapper<WrkMast>().eq("wrk_no", params.getTaskNo()));
        String validateMsg = validateReassignInboundTask(wrkMast);
        if (!Cools.isEmpty(validateMsg)) {
            return R.error(validateMsg);
        }
        LocMast currentLoc = locMastService.selectById(wrkMast.getLocNo());
        if (Cools.isEmpty(currentLoc)) {
            return R.error("当前目标库位不存在");
        }
        Integer preferredArea = resolveReassignArea(wrkMast, currentLoc);
        if (preferredArea == null) {
            return R.error("无法确定任务所属库区");
        }
        List<Integer> candidateCrnNos = buildReassignCandidateCrnNos(preferredArea, wrkMast.getCrnNo());
        if (candidateCrnNos.isEmpty()) {
            return R.error("当前库区没有其他堆垛机可供重分配");
        }
        LocTypeDto locTypeDto = buildReassignLocTypeDto(currentLoc);
        StartupDto startupDto = commonService.findRun2InboundLocByCandidateCrnNos(
                wrkMast.getSourceStaNo(), wrkMast.getIoType(), preferredArea, candidateCrnNos, locTypeDto);
        if (startupDto == null || Cools.isEmpty(startupDto.getLocNo())) {
            return R.error("当前库区没有可重新分配的空库位");
        }
        LocMast targetLoc = locMastService.selectById(startupDto.getLocNo());
        if (Cools.isEmpty(targetLoc)) {
            throw new CoolException("新目标库位不存在");
        }
        if (!"O".equals(targetLoc.getLocSts())) {
            throw new CoolException(targetLoc.getLocNo() + "目标库位已被占用");
        }
        Date now = new Date();
        updateReassignTargetLoc(targetLoc, wrkMast, currentLoc, now);
        updateReassignWorkMast(wrkMast, startupDto, now);
        releaseOldReservedLocIfNeeded(currentLoc, targetLoc.getLocNo(), now);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("locNo", Utils.WMSLocToWCSLoc(targetLoc.getLocNo()));
        return R.ok("操作成功").add(result);
    }
    private boolean requiresOutboundErpConfirm(WrkMast wrkMast) {
        Integer ioType = wrkMast == null ? null : wrkMast.getIoType();
        return ioType != null && (ioType == 101 || ioType == 103 || ioType == 104 || ioType == 107 || ioType == 110);
@@ -643,6 +705,172 @@
        return null;
    }
    private String validateReassignInboundTask(WrkMast wrkMast) {
        if (wrkMast == null) {
            return "任务不存在";
        }
        if (wrkMast.getIoType() == null || (wrkMast.getIoType() != 1 && wrkMast.getIoType() != 10)) {
            return "当前任务不是入库任务";
        }
        if (!Objects.equals(wrkMast.getWrkSts(), 2L)) {
            return "当前任务状态不允许重新分配入库位";
        }
        if (wrkMast.getCrnNo() == null) {
            return "当前任务未分配堆垛机";
        }
        if (Cools.isEmpty(wrkMast.getLocNo())) {
            return "当前任务未分配目标库位";
        }
        if (wrkMast.getSourceStaNo() == null) {
            return "当前任务缺少源站信息";
        }
        return null;
    }
    private Integer resolveReassignArea(WrkMast wrkMast, LocMast currentLoc) {
        Integer stationArea = Utils.getStationStorageArea(wrkMast.getSourceStaNo());
        if (belongsToArea(stationArea, wrkMast.getCrnNo(), currentLoc)) {
            return stationArea;
        }
        Integer fallbackArea = findAreaByCurrentTask(wrkMast.getCrnNo(), currentLoc);
        if (fallbackArea != null) {
            return fallbackArea;
        }
        return stationArea;
    }
    private Integer findAreaByCurrentTask(Integer currentCrnNo, LocMast currentLoc) {
        for (int area = 1; area <= 3; area++) {
            if (belongsToArea(area, currentCrnNo, currentLoc)) {
                return area;
            }
        }
        return null;
    }
    private boolean belongsToArea(Integer area, Integer currentCrnNo, LocMast currentLoc) {
        if (area == null || area <= 0) {
            return false;
        }
        RowLastno areaRowLastno = rowLastnoService.selectById(area);
        if (areaRowLastno == null) {
            return false;
        }
        Integer startCrnNo = resolveAreaStartCrnNo(areaRowLastno);
        Integer endCrnNo = resolveAreaEndCrnNo(areaRowLastno, startCrnNo);
        if (currentCrnNo != null && currentCrnNo >= startCrnNo && currentCrnNo <= endCrnNo) {
            return true;
        }
        Integer row = currentLoc == null ? null : currentLoc.getRow1();
        Integer startRow = areaRowLastno.getsRow();
        Integer endRow = areaRowLastno.geteRow();
        return row != null && startRow != null && endRow != null && row >= startRow && row <= endRow;
    }
    private List<Integer> buildReassignCandidateCrnNos(Integer area, Integer currentCrnNo) {
        RowLastno areaRowLastno = rowLastnoService.selectById(area);
        if (areaRowLastno == null) {
            throw new CoolException("未找到库区轮询规则");
        }
        int startCrnNo = resolveAreaStartCrnNo(areaRowLastno);
        int endCrnNo = resolveAreaEndCrnNo(areaRowLastno, startCrnNo);
        if (currentCrnNo == null || currentCrnNo < startCrnNo || currentCrnNo > endCrnNo) {
            throw new CoolException("当前任务堆垛机不在所属库区范围内");
        }
        List<Integer> candidateCrnNos = new ArrayList<>();
        for (int crnNo = currentCrnNo - 1; crnNo >= startCrnNo; crnNo--) {
            candidateCrnNos.add(crnNo);
        }
        for (int crnNo = endCrnNo; crnNo > currentCrnNo; crnNo--) {
            candidateCrnNos.add(crnNo);
        }
        return candidateCrnNos;
    }
    private int resolveAreaStartCrnNo(RowLastno areaRowLastno) {
        if (areaRowLastno.getsCrnNo() != null && areaRowLastno.getsCrnNo() > 0) {
            return areaRowLastno.getsCrnNo();
        }
        return 1;
    }
    private int resolveAreaEndCrnNo(RowLastno areaRowLastno, int startCrnNo) {
        if (areaRowLastno.geteCrnNo() != null && areaRowLastno.geteCrnNo() >= startCrnNo) {
            return areaRowLastno.geteCrnNo();
        }
        int crnQty = areaRowLastno.getCrnQty() == null || areaRowLastno.getCrnQty() <= 0 ? 1 : areaRowLastno.getCrnQty();
        return startCrnNo + crnQty - 1;
    }
    private LocTypeDto buildReassignLocTypeDto(LocMast currentLoc) {
        LocTypeDto locTypeDto = new LocTypeDto();
        if (currentLoc == null) {
            return locTypeDto;
        }
        locTypeDto.setLocType1(normalizeLocType(currentLoc.getLocType1()));
        locTypeDto.setLocType2(normalizeLocType(currentLoc.getLocType2()));
        locTypeDto.setLocType3(normalizeLocType(currentLoc.getLocType3()));
        return locTypeDto;
    }
    private Short normalizeLocType(Short locType) {
        return locType == null || locType <= 0 ? null : locType;
    }
    private void updateReassignTargetLoc(LocMast targetLoc, WrkMast wrkMast, LocMast currentLoc, Date now) {
        targetLoc.setLocSts("S");
        targetLoc.setModiUser(WCS_SYNC_USER);
        targetLoc.setModiTime(now);
        if (!Cools.isEmpty(wrkMast.getBarcode())) {
            targetLoc.setBarcode(wrkMast.getBarcode());
        } else if (!Cools.isEmpty(currentLoc) && !Cools.isEmpty(currentLoc.getBarcode())) {
            targetLoc.setBarcode(currentLoc.getBarcode());
        } else {
            targetLoc.setBarcode("");
        }
        if (wrkMast.getScWeight() != null) {
            targetLoc.setScWeight(wrkMast.getScWeight());
        } else if (!Cools.isEmpty(currentLoc) && currentLoc.getScWeight() != null) {
            targetLoc.setScWeight(currentLoc.getScWeight());
        } else {
            targetLoc.setScWeight(BigDecimal.ZERO);
        }
        if (!locMastService.updateById(targetLoc)) {
            throw new CoolException("改变库位状态失败");
        }
    }
    private void updateReassignWorkMast(WrkMast wrkMast, StartupDto startupDto, Date now) {
        wrkMast.setLocNo(startupDto.getLocNo());
        wrkMast.setCrnNo(startupDto.getCrnNo());
        if (startupDto.getStaNo() != null) {
            wrkMast.setStaNo(startupDto.getStaNo());
        }
        wrkMast.setWrkSts(2L);
        wrkMast.setModiUser(WCS_SYNC_USER);
        wrkMast.setModiTime(now);
        if (!wrkMastService.updateById(wrkMast)) {
            throw new CoolException("修改工作档失败");
        }
    }
    private void releaseOldReservedLocIfNeeded(LocMast currentLoc, String newLocNo, Date now) {
        if (currentLoc == null || Cools.isEmpty(currentLoc.getLocNo()) || currentLoc.getLocNo().equals(newLocNo)) {
            return;
        }
        if (!"S".equals(currentLoc.getLocSts())) {
            return;
        }
        currentLoc.setLocSts("O");
        currentLoc.setBarcode("");
        currentLoc.setScWeight(BigDecimal.ZERO);
        currentLoc.setModiUser(WCS_SYNC_USER);
        currentLoc.setModiTime(now);
        if (!locMastService.updateById(currentLoc)) {
            throw new CoolException("释放原目标库位失败");
        }
    }
    /**
     * 按任务类型选择 WCS 接口地址。
     * in -> 入库接口
src/main/java/com/zy/asrs/task/AutoFrontLocMoveScheduler.java
New file
@@ -0,0 +1,47 @@
package com.zy.asrs.task;
import com.core.common.Cools;
import com.zy.asrs.task.core.ReturnT;
import com.zy.asrs.task.handler.AutoFrontLocMoveHandler;
import com.zy.asrs.task.support.AutoFrontLocMoveConfigResolver;
import com.zy.asrs.task.support.AutoFrontLocMoveSettings;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class AutoFrontLocMoveScheduler {
    @Autowired
    private AutoFrontLocMoveConfigResolver configResolver;
    @Autowired
    private AutoFrontLocMoveHandler handler;
    /**
     * 由于定时器固定 5 秒触发一次,实际业务执行频率由配置中的 intervalSeconds 控制。
     */
    private volatile long lastRunAt = 0L;
    @Scheduled(cron = "0/5 * * * * ? ")
    public synchronized void execute() {
        AutoFrontLocMoveSettings settings = configResolver.resolve();
        if (settings == null || !settings.isEnabled() || Cools.isEmpty(settings.getRules())) {
            lastRunAt = 0L;
            return;
        }
        long now = System.currentTimeMillis();
        long intervalMillis = Math.max(1, settings.getIntervalSeconds()) * 1000L;
        if (lastRunAt > 0 && now - lastRunAt < intervalMillis) {
            return;
        }
        lastRunAt = now;
        ReturnT<String> result = handler.start(settings);
        if (!result.isSuccess()) {
            log.warn("前排补货移库执行失败:{}", result.getMsg());
        }
    }
}
src/main/java/com/zy/asrs/task/handler/AutoFrontLocMoveHandler.java
New file
@@ -0,0 +1,238 @@
package com.zy.asrs.task.handler;
import com.baomidou.mybatisplus.mapper.EntityWrapper;
import com.core.common.Cools;
import com.zy.asrs.entity.BasCrnp;
import com.zy.asrs.entity.LocDetl;
import com.zy.asrs.entity.LocMast;
import com.zy.asrs.entity.WrkMast;
import com.zy.asrs.service.BasCrnpService;
import com.zy.asrs.service.LocDetlService;
import com.zy.asrs.service.LocMastService;
import com.zy.asrs.service.WorkService;
import com.zy.asrs.service.WrkMastService;
import com.zy.asrs.task.AbstractHandler;
import com.zy.asrs.task.core.ReturnT;
import com.zy.asrs.task.support.AutoFrontLocMoveSettings;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Slf4j
@Service
public class AutoFrontLocMoveHandler extends AbstractHandler<String> {
    @Autowired
    private BasCrnpService basCrnpService;
    @Autowired
    private WrkMastService wrkMastService;
    @Autowired
    private LocMastService locMastService;
    @Autowired
    private LocDetlService locDetlService;
    @Autowired
    private WorkService workService;
    public synchronized ReturnT<String> start(AutoFrontLocMoveSettings settings) {
        if (settings == null || !settings.isEnabled() || Cools.isEmpty(settings.getRules())) {
            return SUCCESS;
        }
        try {
            // 每次调度按配置逐台堆垛机尝试,单台只成功下发一笔移库,避免瞬时堆积。
            for (AutoFrontLocMoveSettings.Rule rule : settings.getRules()) {
                dispatch(rule, settings.getUserId());
            }
        } catch (Exception e) {
            log.error("前排补货移库执行失败", e);
            return FAIL.setMsg(e.getMessage());
        }
        return SUCCESS;
    }
    void dispatch(AutoFrontLocMoveSettings.Rule rule, Long userId) {
        if (rule == null || rule.getCrnNo() == null || Cools.isEmpty(rule.getFrontRowList())) {
            return;
        }
        if (!isCraneAvailable(rule.getCrnNo())) {
            return;
        }
        List<LocMast> targetLocs = loadTargetLocs(rule);
        if (targetLocs.isEmpty()) {
            return;
        }
        List<LocMast> sourceLocs = loadSourceLocs(rule);
        if (sourceLocs.isEmpty()) {
            return;
        }
        // 目标库位按前排优先顺序遍历,来源库位按配置顺序或“离前排更远”优先遍历。
        for (LocMast targetLoc : targetLocs) {
            for (LocMast sourceLoc : sourceLocs) {
                if (!isCompatible(sourceLoc, targetLoc)) {
                    continue;
                }
                try {
                    workService.locMove(sourceLoc.getLocNo(), targetLoc.getLocNo(), userId);
                    log.info("前排补货移库已下发,堆垛机{},源库位{},目标库位{}",
                            rule.getCrnNo(), sourceLoc.getLocNo(), targetLoc.getLocNo());
                    return;
                } catch (Exception e) {
                    log.warn("前排补货移库下发失败,堆垛机{},源库位{},目标库位{},原因:{}",
                            rule.getCrnNo(), sourceLoc.getLocNo(), targetLoc.getLocNo(), e.getMessage());
                }
            }
        }
    }
    private boolean isCraneAvailable(Integer crnNo) {
        BasCrnp crnp = basCrnpService.selectById(crnNo);
        if (Cools.isEmpty(crnp)) {
            return false;
        }
        if ("N".equalsIgnoreCase(crnp.getInEnable()) || "N".equalsIgnoreCase(crnp.getOutEnable())) {
            return false;
        }
        if (crnp.getCrnSts() == null || crnp.getCrnSts() != 3) {
            return false;
        }
        if (crnp.getCrnErr() != null && crnp.getCrnErr() != 0L) {
            return false;
        }
        if (crnp.getTankQty() != null && crnp.getTankQty() == 0) {
            return false;
        }
        EntityWrapper<WrkMast> wrapper = new EntityWrapper<>();
        wrapper.eq("crn_no", crnNo);
        wrapper.last(" and wrk_sts in (2,3,4,11,12)");
        List<WrkMast> activeWorks = wrkMastService.selectList(wrapper);
        // 有执行中/待执行工作时不再追加自动移库,避免与人工或业务任务抢设备。
        return Cools.isEmpty(activeWorks);
    }
    private List<LocMast> loadTargetLocs(AutoFrontLocMoveSettings.Rule rule) {
        EntityWrapper<LocMast> wrapper = new EntityWrapper<>();
        wrapper.eq("crn_no", rule.getCrnNo());
        wrapper.eq("loc_sts", "O");
        wrapper.in("row1", rule.getFrontRowList());
        List<LocMast> candidates = locMastService.selectList(wrapper);
        List<LocMast> targetLocs = new ArrayList<>();
        if (!Cools.isEmpty(candidates)) {
            for (LocMast candidate : candidates) {
                if (isUsableLoc(candidate)) {
                    targetLocs.add(candidate);
                }
            }
        }
        targetLocs.sort(buildRowComparator(rule.getFrontRowList()));
        return targetLocs;
    }
    private List<LocMast> loadSourceLocs(AutoFrontLocMoveSettings.Rule rule) {
        EntityWrapper<LocMast> wrapper = new EntityWrapper<>();
        wrapper.eq("crn_no", rule.getCrnNo());
        wrapper.eq("loc_sts", "F");
        if (!Cools.isEmpty(rule.getSourceRowList())) {
            wrapper.in("row1", rule.getSourceRowList());
        }
        List<LocMast> candidates = locMastService.selectList(wrapper);
        if (Cools.isEmpty(candidates)) {
            return new ArrayList<>();
        }
        Set<Integer> frontRows = new HashSet<>(rule.getFrontRowList());
        List<LocMast> sourceLocs = new ArrayList<>();
        for (LocMast candidate : candidates) {
            if (!isUsableLoc(candidate)) {
                continue;
            }
            // 未显式配置来源排时,默认只从“非前排”搬货,避免刚补到前排又被搬走。
            if (Cools.isEmpty(rule.getSourceRowList()) && frontRows.contains(candidate.getRow1())) {
                continue;
            }
            if (!hasMovableStock(candidate.getLocNo())) {
                continue;
            }
            sourceLocs.add(candidate);
        }
        if (!Cools.isEmpty(rule.getSourceRowList())) {
            sourceLocs.sort(buildRowComparator(rule.getSourceRowList()));
        } else {
            sourceLocs.sort(buildDefaultSourceComparator(rule.getFrontRowList()));
        }
        return sourceLocs;
    }
    private Comparator<LocMast> buildRowComparator(List<Integer> rowOrder) {
        Map<Integer, Integer> sortIndex = new HashMap<>();
        for (int i = 0; i < rowOrder.size(); i++) {
            sortIndex.put(rowOrder.get(i), i);
        }
        return Comparator
                .comparingInt((LocMast loc) -> sortIndex.getOrDefault(loc.getRow1(), Integer.MAX_VALUE))
                .thenComparing(LocMast::getBay1, Comparator.nullsLast(Integer::compareTo))
                .thenComparing(LocMast::getLev1, Comparator.nullsLast(Integer::compareTo))
                .thenComparing(LocMast::getGro1, Comparator.nullsLast(Integer::compareTo))
                .thenComparing(LocMast::getLocNo, Comparator.nullsLast(String::compareTo));
    }
    private Comparator<LocMast> buildDefaultSourceComparator(List<Integer> frontRowOrder) {
        // 前排是升序时,来源默认从更后排开始;前排是降序时,来源默认从更前排开始。
        boolean descending = frontRowOrder.size() > 1 && frontRowOrder.get(0) > frontRowOrder.get(frontRowOrder.size() - 1);
        Comparator<Integer> rowComparator = descending ? Comparator.reverseOrder() : Integer::compareTo;
        return Comparator
                .comparing(LocMast::getRow1, Comparator.nullsLast(rowComparator))
                .thenComparing(LocMast::getBay1, Comparator.nullsLast(Integer::compareTo))
                .thenComparing(LocMast::getLev1, Comparator.nullsLast(Integer::compareTo))
                .thenComparing(LocMast::getGro1, Comparator.nullsLast(Integer::compareTo))
                .thenComparing(LocMast::getLocNo, Comparator.nullsLast(String::compareTo));
    }
    private boolean hasMovableStock(String locNo) {
        List<LocDetl> locDetls = locDetlService.selectList(new EntityWrapper<LocDetl>().eq("loc_no", locNo));
        if (Cools.isEmpty(locDetls)) {
            return false;
        }
        // 任何一条库存明细被冻结,都视为该托盘不可被自动策略搬运。
        for (LocDetl locDetl : locDetls) {
            if (locDetl != null && locDetl.getFrozen() != null && locDetl.getFrozen() == 1) {
                return false;
            }
        }
        return true;
    }
    private boolean isUsableLoc(LocMast locMast) {
        return locMast != null
                && (locMast.getFrozen() == null || locMast.getFrozen() == 0)
                && !"N".equalsIgnoreCase(locMast.getOutEnable());
    }
    private boolean isCompatible(LocMast sourceLoc, LocMast targetLoc) {
        if (sourceLoc == null || targetLoc == null) {
            return false;
        }
        if (sourceLoc.getLocNo() != null && sourceLoc.getLocNo().equals(targetLoc.getLocNo())) {
            return false;
        }
        // 自动移库只做同类库位补位,减少因库位画像差异导致的后续作业风险。
        return sameOrBlank(sourceLoc.getWhsType(), targetLoc.getWhsType())
                && sameOrBlank(sourceLoc.getLocType1(), targetLoc.getLocType1())
                && sameOrBlank(sourceLoc.getLocType2(), targetLoc.getLocType2())
                && sameOrBlank(sourceLoc.getLocType3(), targetLoc.getLocType3());
    }
    private boolean sameOrBlank(Object source, Object target) {
        return source == null || target == null || source.equals(target);
    }
}
src/main/java/com/zy/asrs/task/support/AutoFrontLocMoveConfigResolver.java
New file
@@ -0,0 +1,141 @@
package com.zy.asrs.task.support;
import com.alibaba.fastjson.JSON;
import com.core.common.Cools;
import com.zy.system.entity.Config;
import com.zy.system.service.ConfigService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
@Slf4j
@Service
public class AutoFrontLocMoveConfigResolver {
    public static final String CONFIG_CODE = "AutoFrontLocMove";
    private static final int DEFAULT_INTERVAL_SECONDS = 60;
    private static final long DEFAULT_USER_ID = 9527L;
    @Autowired
    private ConfigService configService;
    /**
     * 从 sys_config 读取 JSON 配置。
     * 配置缺失、停用或解析失败时,回退成“关闭状态”的默认配置。
     */
    public AutoFrontLocMoveSettings resolve() {
        AutoFrontLocMoveSettings settings = defaults();
        Config config = configService.selectConfigByCode(CONFIG_CODE);
        if (!isConfigEnabled(config)) {
            return settings;
        }
        try {
            AutoFrontLocMoveSettings parsed = JSON.parseObject(config.getValue(), AutoFrontLocMoveSettings.class);
            if (parsed == null) {
                return settings;
            }
            normalize(parsed);
            return parsed;
        } catch (Exception e) {
            log.error("前排补货移库配置解析失败,code={}, value={}", CONFIG_CODE, config.getValue(), e);
            return settings;
        }
    }
    AutoFrontLocMoveSettings defaults() {
        AutoFrontLocMoveSettings settings = new AutoFrontLocMoveSettings();
        settings.setEnabled(false);
        settings.setIntervalSeconds(DEFAULT_INTERVAL_SECONDS);
        settings.setUserId(DEFAULT_USER_ID);
        settings.setRules(new ArrayList<>());
        return settings;
    }
    void normalize(AutoFrontLocMoveSettings settings) {
        if (settings.getIntervalSeconds() == null || settings.getIntervalSeconds() <= 0) {
            settings.setIntervalSeconds(DEFAULT_INTERVAL_SECONDS);
        }
        if (settings.getUserId() == null || settings.getUserId() <= 0L) {
            settings.setUserId(DEFAULT_USER_ID);
        }
        List<AutoFrontLocMoveSettings.Rule> normalizedRules = new ArrayList<>();
        if (!Cools.isEmpty(settings.getRules())) {
            for (AutoFrontLocMoveSettings.Rule rule : settings.getRules()) {
                if (rule == null || rule.getCrnNo() == null) {
                    continue;
                }
                List<Integer> frontRows = parseRows(rule.getFrontRows());
                if (frontRows.isEmpty()) {
                    continue;
                }
                // 解析结果预先挂在 rule 上,调度执行时直接使用,避免每次扫描重复拆字符串。
                rule.setFrontRowList(frontRows);
                rule.setSourceRowList(parseRows(rule.getSourceRows()));
                normalizedRules.add(rule);
            }
        }
        settings.setRules(normalizedRules);
        if (normalizedRules.isEmpty()) {
            settings.setEnabled(false);
        }
    }
    List<Integer> parseRows(String rawRows) {
        LinkedHashSet<Integer> rows = new LinkedHashSet<>();
        if (Cools.isEmpty(rawRows)) {
            return new ArrayList<>();
        }
        String[] parts = rawRows.split(",");
        for (String part : parts) {
            String token = part == null ? null : part.trim();
            if (Cools.isEmpty(token)) {
                continue;
            }
            if (token.contains("-")) {
                String[] range = token.split("-");
                if (range.length != 2) {
                    continue;
                }
                Integer start = safeParseInt(range[0]);
                Integer end = safeParseInt(range[1]);
                if (start == null || end == null || start <= 0 || end <= 0) {
                    continue;
                }
                // 支持正序/倒序区间,例如 1-3 或 20-18。
                int step = start <= end ? 1 : -1;
                for (int row = start; ; row += step) {
                    rows.add(row);
                    if (row == end) {
                        break;
                    }
                }
                continue;
            }
            Integer row = safeParseInt(token);
            if (row != null && row > 0) {
                rows.add(row);
            }
        }
        return new ArrayList<>(rows);
    }
    private boolean isConfigEnabled(Config config) {
        return config != null
                && config.getStatus() != null
                && config.getStatus() == 1
                && !Cools.isEmpty(config.getValue());
    }
    private Integer safeParseInt(String raw) {
        try {
            return Integer.parseInt(raw.trim());
        } catch (Exception ignore) {
            return null;
        }
    }
}
src/main/java/com/zy/asrs/task/support/AutoFrontLocMoveSettings.java
New file
@@ -0,0 +1,61 @@
package com.zy.asrs.task.support;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
@Data
public class AutoFrontLocMoveSettings implements Serializable {
    private static final long serialVersionUID = 1L;
    private boolean enabled;
    /**
     * 调度执行间隔,单位秒。
     */
    private Integer intervalSeconds = 60;
    /**
     * 自动移库的操作人,默认沿用历史自动任务用户。
     */
    private Long userId = 9527L;
    /**
     * 每台堆垛机对应的前排补货规则。
     */
    private List<Rule> rules = new ArrayList<>();
    @Data
    public static class Rule implements Serializable {
        private static final long serialVersionUID = 1L;
        /**
         * 堆垛机号。
         */
        private Integer crnNo;
        /**
         * 需要优先补满的排号,支持 1-3,5,8 这种格式,顺序即优先级。
         */
        private String frontRows;
        /**
         * 可作为来源的排号,支持 20-4,2 这种格式;为空时默认取“非前排”的其余排。
         */
        private String sourceRows;
        /**
         * 解析后的前排顺序,运行时使用。
         */
        private List<Integer> frontRowList = new ArrayList<>();
        /**
         * 解析后的来源排顺序,运行时使用。
         */
        private List<Integer> sourceRowList = new ArrayList<>();
    }
}
src/main/java/com/zy/common/config/ControllerResAdvice.java
@@ -113,6 +113,7 @@
        }
        return uri.contains("/open/asrs")
                || uri.contains("/wcs/openapi/report")
                || uri.contains("/wcs/openapi/reassign/loc")
                || uri.contains("/rpc/pakin/loc");
    }
@@ -120,6 +121,9 @@
        if (uri != null && uri.contains("/wcs/openapi/report")) {
            return "WCS回写";
        }
        if (uri != null && uri.contains("/wcs/openapi/reassign/loc")) {
            return "WCS重分配入库";
        }
        if (uri != null && uri.contains("/rpc/pakin/loc")) {
            return "WCS入库";
        }
src/main/java/com/zy/common/service/CommonService.java
@@ -216,6 +216,51 @@
    }
    /**
     * 供 6.15 重分配接口复用:按外部指定的堆垛机顺序,在指定库区内找新的入库位。
     *
     * 这里不推进 row_lastno 游标,只负责一次性的路径校验 + 设备校验 + 空库位搜索。
     */
    public StartupDto findRun2InboundLocByCandidateCrnNos(Integer sourceStaNo, Integer staDescId, Integer preferredArea,
                                                          List<Integer> candidateCrnNos, LocTypeDto locTypeDto) {
        if (sourceStaNo == null) {
            throw new CoolException("源站不能为空");
        }
        if (Cools.isEmpty(candidateCrnNos)) {
            return null;
        }
        Integer whsType = Utils.GetWhsType(sourceStaNo);
        RowLastno defaultRowLastno = rowLastnoService.selectById(whsType);
        if (Cools.isEmpty(defaultRowLastno)) {
            throw new CoolException("站点=" + sourceStaNo + " 未查询到对应的库位规则");
        }
        RowLastno searchRowLastno = defaultRowLastno;
        if (preferredArea != null && preferredArea > 0) {
            RowLastno areaRowLastno = rowLastnoService.selectById(preferredArea);
            if (Cools.isEmpty(areaRowLastno)) {
                throw new CoolException("未找到库区轮询规则");
            }
            searchRowLastno = areaRowLastno;
        }
        RowLastnoType rowLastnoType = rowLastnoTypeService.selectById(searchRowLastno.getTypeId());
        if (Cools.isEmpty(rowLastnoType)) {
            throw new CoolException("数据异常,请联系管理员===》库位规则类型未知");
        }
        if (rowLastnoType.getType() != 1 && rowLastnoType.getType() != 2) {
            throw new CoolException("当前仓库不支持重新分配入库位");
        }
        StartupDto startupDto = new StartupDto();
        LocMast locMast = findRun2EmptyLocByCrnNos(searchRowLastno, rowLastnoType, candidateCrnNos, locTypeDto,
                staDescId, sourceStaNo, startupDto, preferredArea, "reassign-inbound");
        if (Cools.isEmpty(locMast) || !"O".equals(locMast.getLocSts())) {
            return null;
        }
        startupDto.setSourceStaNo(sourceStaNo);
        startupDto.setCrnNo(locMast.getCrnNo());
        startupDto.setLocNo(locMast.getLocNo());
        return startupDto;
    }
    /**
     * 空托盘识别规则:
     * 1. 以组托档物料编码 matnr=emptyPallet 为主,不再依赖 ioType=10。
     * 2. 保留 staDescId=10 的兼容判断,避免旧链路还未切换时行为突变。
src/main/resources/sql/20260407_auto_front_loc_move_config.sql
New file
@@ -0,0 +1,11 @@
IF NOT EXISTS (SELECT 1 FROM sys_config WHERE code = 'AutoFrontLocMove')
BEGIN
    INSERT INTO sys_config (name, code, value, type, status)
    VALUES (
        N'前排补货移库配置',
        'AutoFrontLocMove',
        N'{"enabled":false,"intervalSeconds":60,"userId":9527,"rules":[{"crnNo":1,"frontRows":"1-3","sourceRows":"4-20"},{"crnNo":2,"frontRows":"20-18","sourceRows":"17-1"}]}',
        2,
        1
    );
END;
src/test/java/com/zy/asrs/task/handler/AutoFrontLocMoveHandlerTest.java
New file
@@ -0,0 +1,100 @@
package com.zy.asrs.task.handler;
import com.zy.asrs.entity.BasCrnp;
import com.zy.asrs.entity.LocDetl;
import com.zy.asrs.entity.LocMast;
import com.zy.asrs.service.BasCrnpService;
import com.zy.asrs.service.LocDetlService;
import com.zy.asrs.service.LocMastService;
import com.zy.asrs.service.WorkService;
import com.zy.asrs.service.WrkMastService;
import com.zy.asrs.task.support.AutoFrontLocMoveSettings;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
import java.util.Collections;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AutoFrontLocMoveHandlerTest {
    @InjectMocks
    private AutoFrontLocMoveHandler handler;
    @Mock
    private BasCrnpService basCrnpService;
    @Mock
    private WrkMastService wrkMastService;
    @Mock
    private LocMastService locMastService;
    @Mock
    private LocDetlService locDetlService;
    @Mock
    private WorkService workService;
    @Test
    void shouldCreateMoveTaskForFirstFrontEmptyLoc() {
        AutoFrontLocMoveSettings settings = new AutoFrontLocMoveSettings();
        settings.setEnabled(true);
        settings.setUserId(9527L);
        AutoFrontLocMoveSettings.Rule rule = new AutoFrontLocMoveSettings.Rule();
        rule.setCrnNo(1);
        rule.setFrontRowList(Arrays.asList(1, 2));
        settings.setRules(Collections.singletonList(rule));
        BasCrnp basCrnp = new BasCrnp();
        basCrnp.setCrnNo(1);
        basCrnp.setInEnable("Y");
        basCrnp.setOutEnable("Y");
        basCrnp.setCrnSts(3);
        basCrnp.setCrnErr(0L);
        when(basCrnpService.selectById(1)).thenReturn(basCrnp);
        when(wrkMastService.selectList(any())).thenReturn(Collections.emptyList());
        LocMast targetLoc = buildLoc("0101010", 1, "O");
        LocMast incompatibleSource = buildLoc("0105010", 5, "F");
        incompatibleSource.setWhsType(2L);
        LocMast compatibleSource = buildLoc("0106010", 6, "F");
        when(locMastService.selectList(any()))
                .thenReturn(Collections.singletonList(targetLoc))
                .thenReturn(Arrays.asList(incompatibleSource, compatibleSource));
        LocDetl locDetl = new LocDetl();
        locDetl.setLocNo(compatibleSource.getLocNo());
        locDetl.setFrozen(0);
        when(locDetlService.selectList(any())).thenReturn(Collections.singletonList(locDetl));
        doNothing().when(workService).locMove(compatibleSource.getLocNo(), targetLoc.getLocNo(), 9527L);
        handler.start(settings);
        verify(workService).locMove(compatibleSource.getLocNo(), targetLoc.getLocNo(), 9527L);
    }
    private LocMast buildLoc(String locNo, Integer row, String locSts) {
        LocMast locMast = new LocMast();
        locMast.setLocNo(locNo);
        locMast.setCrnNo(1);
        locMast.setRow1(row);
        locMast.setBay1(1);
        locMast.setLev1(1);
        locMast.setGro1(1);
        locMast.setWhsType(1L);
        locMast.setLocType1((short) 1);
        locMast.setLocType2((short) 1);
        locMast.setLocType3((short) 1);
        locMast.setOutEnable("Y");
        locMast.setLocSts(locSts);
        locMast.setFrozen(0);
        return locMast;
    }
}
src/test/java/com/zy/asrs/task/support/AutoFrontLocMoveConfigResolverTest.java
New file
@@ -0,0 +1,52 @@
package com.zy.asrs.task.support;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
class AutoFrontLocMoveConfigResolverTest {
    private final AutoFrontLocMoveConfigResolver resolver = new AutoFrontLocMoveConfigResolver();
    @Test
    void parseRowsShouldKeepConfiguredOrderAndDeduplicate() {
        assertEquals(Arrays.asList(20, 19, 18, 15, 14, 13, 8),
                resolver.parseRows("20-18,15-13,8,18"));
    }
    @Test
    void normalizeShouldApplyDefaultsAndDropInvalidRules() {
        AutoFrontLocMoveSettings settings = new AutoFrontLocMoveSettings();
        settings.setEnabled(true);
        settings.setIntervalSeconds(0);
        settings.setUserId(0L);
        AutoFrontLocMoveSettings.Rule invalidRule = new AutoFrontLocMoveSettings.Rule();
        invalidRule.setCrnNo(1);
        AutoFrontLocMoveSettings.Rule validRule = new AutoFrontLocMoveSettings.Rule();
        validRule.setCrnNo(2);
        validRule.setFrontRows("1-3");
        validRule.setSourceRows("6-4");
        settings.setRules(Arrays.asList(invalidRule, validRule));
        resolver.normalize(settings);
        assertEquals(Integer.valueOf(60), settings.getIntervalSeconds());
        assertEquals(Long.valueOf(9527L), settings.getUserId());
        assertEquals(1, settings.getRules().size());
        assertEquals(Arrays.asList(1, 2, 3), settings.getRules().get(0).getFrontRowList());
        assertEquals(Arrays.asList(6, 5, 4), settings.getRules().get(0).getSourceRowList());
        AutoFrontLocMoveSettings onlyInvalid = new AutoFrontLocMoveSettings();
        onlyInvalid.setEnabled(true);
        onlyInvalid.setRules(Collections.singletonList(invalidRule));
        resolver.normalize(onlyInvalid);
        assertFalse(onlyInvalid.isEnabled());
    }
}