Junjie
1 天以前 ccab5f516f055c5e8d8015442d9860a18072f508
#跑库功能V3.0.1.9
17个文件已添加
3个文件已修改
1676 ■■■■■ 已修改文件
src/main/java/com/zy/asrs/controller/WrkCyclePlanController.java 178 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/domain/param/CreateCyclePlanParam.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/entity/WrkCyclePlan.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/entity/WrkCyclePlanLoc.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/mapper/WrkCyclePlanLocMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/mapper/WrkCyclePlanMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/service/WrkCyclePlanLocService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/service/WrkCyclePlanService.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/service/impl/WrkCyclePlanLocServiceImpl.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/service/impl/WrkCyclePlanServiceImpl.java 487 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/task/CyclePlanScheduler.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/task/WrkMastFinalizeProcessor.java 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/common/service/CommonService.java 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/enums/CyclePlanLocStatus.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/enums/CyclePlanStatus.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application.yml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260505_add_wrk_cycle_plan_menu.sql 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260505_create_wrk_cycle_plan.sql 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/wrkCyclePlan/wrkCyclePlan.js 259 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/wrkCyclePlan/wrkCyclePlan.html 184 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/controller/WrkCyclePlanController.java
New file
@@ -0,0 +1,178 @@
package com.zy.asrs.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.core.annotations.ManagerAuth;
import com.core.common.R;
import com.zy.asrs.domain.param.CreateCyclePlanParam;
import com.zy.asrs.entity.BasCrnp;
import com.zy.asrs.entity.BasDualCrnp;
import com.zy.asrs.entity.WrkCyclePlan;
import com.zy.asrs.entity.WrkCyclePlanLoc;
import com.zy.asrs.service.BasCrnpService;
import com.zy.asrs.service.BasDualCrnpService;
import com.zy.asrs.service.WrkCyclePlanLocService;
import com.zy.asrs.service.WrkCyclePlanService;
import com.zy.common.web.BaseController;
import com.zy.core.enums.SlaveType;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@RestController
public class WrkCyclePlanController extends BaseController {
    @Autowired
    private WrkCyclePlanService wrkCyclePlanService;
    @Autowired
    private WrkCyclePlanLocService wrkCyclePlanLocService;
    @Autowired
    private BasCrnpService basCrnpService;
    @Autowired
    private BasDualCrnpService basDualCrnpService;
    @RequestMapping(value = "/wrkCyclePlan/list/auth")
    @ManagerAuth
    public R list(@RequestParam(defaultValue = "1") Integer curr,
                  @RequestParam(defaultValue = "10") Integer limit) {
        QueryWrapper<WrkCyclePlan> wrapper = new QueryWrapper<>();
        wrapper.orderByDesc("id");
        return R.ok(wrkCyclePlanService.page(new Page<>(curr, limit), wrapper));
    }
    @RequestMapping(value = "/wrkCyclePlan/locList/auth")
    @ManagerAuth
    public R locList(@RequestParam Long planId,
                     @RequestParam(defaultValue = "1") Integer curr,
                     @RequestParam(defaultValue = "20") Integer limit) {
        QueryWrapper<WrkCyclePlanLoc> wrapper = new QueryWrapper<>();
        wrapper.eq("plan_id", planId).orderByAsc("seq");
        return R.ok(wrkCyclePlanLocService.page(new Page<>(curr, limit), wrapper));
    }
    @PostMapping(value = "/wrkCyclePlan/create/auth")
    @ManagerAuth
    public R create(@RequestBody CreateCyclePlanParam param) {
        try {
            WrkCyclePlan plan = wrkCyclePlanService.createPlan(param);
            return R.ok(plan);
        } catch (Exception e) {
            return R.error(e.getMessage());
        }
    }
    @PostMapping(value = "/wrkCyclePlan/start/auth")
    @ManagerAuth
    public R start(@RequestBody Map<String, Long> param) {
        try {
            wrkCyclePlanService.startPlan(param.get("planId"));
            return R.ok();
        } catch (Exception e) {
            return R.error(e.getMessage());
        }
    }
    @PostMapping(value = "/wrkCyclePlan/pause/auth")
    @ManagerAuth
    public R pause(@RequestBody Map<String, Long> param) {
        try {
            wrkCyclePlanService.pausePlan(param.get("planId"));
            return R.ok();
        } catch (Exception e) {
            return R.error(e.getMessage());
        }
    }
    @PostMapping(value = "/wrkCyclePlan/resume/auth")
    @ManagerAuth
    public R resume(@RequestBody Map<String, Long> param) {
        try {
            wrkCyclePlanService.resumePlan(param.get("planId"));
            return R.ok();
        } catch (Exception e) {
            return R.error(e.getMessage());
        }
    }
    @PostMapping(value = "/wrkCyclePlan/reset/auth")
    @ManagerAuth
    public R reset(@RequestBody Map<String, Long> param) {
        try {
            wrkCyclePlanService.resetPlan(param.get("planId"));
            return R.ok();
        } catch (Exception e) {
            return R.error(e.getMessage());
        }
    }
    @PostMapping(value = "/wrkCyclePlan/delete/auth")
    @ManagerAuth
    public R delete(@RequestBody Map<String, Long> param) {
        try {
            wrkCyclePlanService.deletePlan(param.get("planId"));
            return R.ok();
        } catch (Exception e) {
            return R.error(e.getMessage());
        }
    }
    @RequestMapping(value = "/wrkCyclePlan/craneOptions/auth")
    @ManagerAuth
    public R craneOptions() {
        List<Map<String, Object>> options = new ArrayList<>();
        List<BasCrnp> crnps = basCrnpService.list(new QueryWrapper<>());
        for (BasCrnp crnp : crnps) {
            Map<String, Object> opt = new HashMap<>();
            opt.put("crnNo", crnp.getCrnNo());
            opt.put("crnType", SlaveType.Crn.name());
            opt.put("label", "堆垛机" + crnp.getCrnNo());
            options.add(opt);
        }
        List<BasDualCrnp> dualCrnps = basDualCrnpService.list(new QueryWrapper<>());
        for (BasDualCrnp dualCrnp : dualCrnps) {
            Map<String, Object> opt = new HashMap<>();
            opt.put("crnNo", dualCrnp.getCrnNo());
            opt.put("crnType", SlaveType.DualCrn.name());
            opt.put("label", "双工位堆垛机" + dualCrnp.getCrnNo());
            options.add(opt);
        }
        return R.ok(options);
    }
    @PostMapping(value = "/wrkCyclePlan/availableRows/auth")
    @ManagerAuth
    public R availableRows(@RequestBody List<CreateCyclePlanParam.CrnOption> crnOptions) {
        List<Integer> rows = new ArrayList<>();
        for (CreateCyclePlanParam.CrnOption opt : crnOptions) {
            if (SlaveType.Crn.name().equals(opt.getCrnType())) {
                BasCrnp crnp = basCrnpService.getOne(new QueryWrapper<BasCrnp>().eq("crn_no", opt.getCrnNo()));
                if (crnp != null) {
                    for (List<Integer> group : crnp.getControlRows$()) {
                        rows.addAll(group);
                    }
                }
            } else if (SlaveType.DualCrn.name().equals(opt.getCrnType())) {
                BasDualCrnp dualCrnp = basDualCrnpService.getOne(new QueryWrapper<BasDualCrnp>().eq("crn_no", opt.getCrnNo()));
                if (dualCrnp != null) {
                    for (List<Integer> group : dualCrnp.getControlRows$()) {
                        rows.addAll(group);
                    }
                }
            }
        }
        Collections.sort(rows);
        return R.ok(rows.stream().distinct().collect(java.util.stream.Collectors.toList()));
    }
}
src/main/java/com/zy/asrs/domain/param/CreateCyclePlanParam.java
New file
@@ -0,0 +1,52 @@
package com.zy.asrs.domain.param;
import lombok.Data;
import java.util.List;
@Data
public class CreateCyclePlanParam {
    /**
     * 堆垛机号列表
     */
    private List<CrnOption> crnOptions;
    /**
     * 选中的排号列表
     */
    private List<Integer> rows;
    /**
     * 列范围-起
     */
    private Integer bayFrom;
    /**
     * 列范围-止
     */
    private Integer bayTo;
    /**
     * 层范围-起
     */
    private Integer levFrom;
    /**
     * 层范围-止
     */
    private Integer levTo;
    @Data
    public static class CrnOption {
        /**
         * 堆垛机号
         */
        private Integer crnNo;
        /**
         * 堆垛机类型:Crn 或 DualCrn
         */
        private String crnType;
    }
}
src/main/java/com/zy/asrs/entity/WrkCyclePlan.java
New file
@@ -0,0 +1,62 @@
package com.zy.asrs.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.zy.core.enums.CyclePlanStatus;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@TableName("asr_wrk_cycle_plan")
public class WrkCyclePlan implements Serializable {
    private static final long serialVersionUID = 1L;
    @ApiModelProperty(value = "主键ID")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @ApiModelProperty(value = "计划编号")
    private String planNo;
    @ApiModelProperty(value = "堆垛机列表JSON")
    private String crnList;
    @ApiModelProperty(value = "计划状态 0=新建 1=运行中 2=暂停 3=已完成 4=已取消")
    private Integer planSts;
    @ApiModelProperty(value = "总库位数")
    private Integer totalCount;
    @ApiModelProperty(value = "已完成数")
    private Integer completedCount;
    @ApiModelProperty(value = "备注")
    private String memo;
    @ApiModelProperty(value = "创建人")
    private Long appeUser;
    @ApiModelProperty(value = "创建时间")
    private Date appeTime;
    @ApiModelProperty(value = "修改人")
    private Long modiUser;
    @ApiModelProperty(value = "修改时间")
    private Date modiTime;
    @TableField(exist = false)
    private String planSts$;
    public String getPlanSts$() {
        CyclePlanStatus status = CyclePlanStatus.get(this.planSts);
        return status != null ? status.desc : String.valueOf(this.planSts);
    }
}
src/main/java/com/zy/asrs/entity/WrkCyclePlanLoc.java
New file
@@ -0,0 +1,65 @@
package com.zy.asrs.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.zy.core.enums.CyclePlanLocStatus;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@TableName("asr_wrk_cycle_plan_loc")
public class WrkCyclePlanLoc implements Serializable {
    private static final long serialVersionUID = 1L;
    @ApiModelProperty(value = "主键ID")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @ApiModelProperty(value = "计划ID")
    private Long planId;
    @ApiModelProperty(value = "源库位号")
    private String locNo;
    @ApiModelProperty(value = "目标库位号")
    private String destLocNo;
    @ApiModelProperty(value = "执行序号")
    private Integer seq;
    @ApiModelProperty(value = "堆垛机号")
    private Integer crnNo;
    @ApiModelProperty(value = "双工位堆垛机号")
    private Integer dualCrnNo;
    @ApiModelProperty(value = "库位计划状态 0=待执行 1=执行中 2=已完成 -1=跳过")
    private Integer locPlanSts;
    @ApiModelProperty(value = "工作号")
    private Integer wrkNo;
    @ApiModelProperty(value = "托盘码快照")
    private String barcode;
    @ApiModelProperty(value = "创建时间")
    private Date appeTime;
    @ApiModelProperty(value = "修改时间")
    private Date modiTime;
    @TableField(exist = false)
    private String locPlanSts$;
    public String getLocPlanSts$() {
        CyclePlanLocStatus status = CyclePlanLocStatus.get(this.locPlanSts);
        return status != null ? status.desc : String.valueOf(this.locPlanSts);
    }
}
src/main/java/com/zy/asrs/mapper/WrkCyclePlanLocMapper.java
New file
@@ -0,0 +1,12 @@
package com.zy.asrs.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.zy.asrs.entity.WrkCyclePlanLoc;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Repository;
@Mapper
@Repository
public interface WrkCyclePlanLocMapper extends BaseMapper<WrkCyclePlanLoc> {
}
src/main/java/com/zy/asrs/mapper/WrkCyclePlanMapper.java
New file
@@ -0,0 +1,12 @@
package com.zy.asrs.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.zy.asrs.entity.WrkCyclePlan;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Repository;
@Mapper
@Repository
public interface WrkCyclePlanMapper extends BaseMapper<WrkCyclePlan> {
}
src/main/java/com/zy/asrs/service/WrkCyclePlanLocService.java
New file
@@ -0,0 +1,8 @@
package com.zy.asrs.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.zy.asrs.entity.WrkCyclePlanLoc;
public interface WrkCyclePlanLocService extends IService<WrkCyclePlanLoc> {
}
src/main/java/com/zy/asrs/service/WrkCyclePlanService.java
New file
@@ -0,0 +1,25 @@
package com.zy.asrs.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.zy.asrs.domain.param.CreateCyclePlanParam;
import com.zy.asrs.entity.WrkCyclePlan;
public interface WrkCyclePlanService extends IService<WrkCyclePlan> {
    WrkCyclePlan createPlan(CreateCyclePlanParam param);
    void startPlan(Long planId);
    void pausePlan(Long planId);
    void resumePlan(Long planId);
    void resetPlan(Long planId);
    void advancePlan(Long planId);
    void onWrkMastComplete(Integer wrkNo);
    void deletePlan(Long planId);
}
src/main/java/com/zy/asrs/service/impl/WrkCyclePlanLocServiceImpl.java
New file
@@ -0,0 +1,12 @@
package com.zy.asrs.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.zy.asrs.entity.WrkCyclePlanLoc;
import com.zy.asrs.mapper.WrkCyclePlanLocMapper;
import com.zy.asrs.service.WrkCyclePlanLocService;
import org.springframework.stereotype.Service;
@Service
public class WrkCyclePlanLocServiceImpl extends ServiceImpl<WrkCyclePlanLocMapper, WrkCyclePlanLoc> implements WrkCyclePlanLocService {
}
src/main/java/com/zy/asrs/service/impl/WrkCyclePlanServiceImpl.java
New file
@@ -0,0 +1,487 @@
package com.zy.asrs.service.impl;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.core.common.Cools;
import com.core.exception.CoolException;
import com.zy.asrs.domain.param.CreateCyclePlanParam;
import com.zy.asrs.domain.param.CreateLocMoveTaskParam;
import com.zy.asrs.entity.*;
import com.zy.asrs.mapper.WrkCyclePlanMapper;
import com.zy.asrs.service.*;
import com.zy.common.service.CommonService;
import com.zy.core.enums.CyclePlanLocStatus;
import com.zy.core.enums.CyclePlanStatus;
import com.zy.core.enums.LocStsType;
import com.zy.core.enums.SlaveType;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
public class WrkCyclePlanServiceImpl extends ServiceImpl<WrkCyclePlanMapper, WrkCyclePlan> implements WrkCyclePlanService {
    @Autowired
    private WrkCyclePlanLocService wrkCyclePlanLocService;
    @Autowired
    private BasCrnpService basCrnpService;
    @Autowired
    private BasDualCrnpService basDualCrnpService;
    @Autowired
    private LocMastService locMastService;
    @Autowired
    private CommonService commonService;
    @Autowired
    private WrkMastService wrkMastService;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public WrkCyclePlan createPlan(CreateCyclePlanParam param) {
        if (param.getCrnOptions() == null || param.getCrnOptions().isEmpty()) {
            throw new CoolException("请选择堆垛机");
        }
        if (param.getBayFrom() == null || param.getBayTo() == null) {
            throw new CoolException("请填写列范围");
        }
        if (param.getLevFrom() == null || param.getLevTo() == null) {
            throw new CoolException("请填写层范围");
        }
        if (param.getBayFrom() > param.getBayTo()) {
            throw new CoolException("列范围起始不能大于结束");
        }
        if (param.getLevFrom() > param.getLevTo()) {
            throw new CoolException("层范围起始不能大于结束");
        }
        // 检查是否有同堆垛机的运行中/暂停计划
        for (CreateCyclePlanParam.CrnOption crnOption : param.getCrnOptions()) {
            checkCrnAvailable(crnOption.getCrnNo(), crnOption.getCrnType());
        }
        // 按堆垛机逐个校验并生成移转计划
        List<WrkCyclePlanLoc> planLocs = new ArrayList<>();
        int seq = 1;
        Set<Integer> userSelectedRows = (param.getRows() != null && !param.getRows().isEmpty())
                ? new HashSet<>(param.getRows()) : null;
        for (CreateCyclePlanParam.CrnOption crnOption : param.getCrnOptions()) {
            List<Integer> crnRows = getCrnControlRows(crnOption.getCrnNo(), crnOption.getCrnType());
            // 过滤用户选择的排号
            List<Integer> rows;
            if (userSelectedRows != null) {
                rows = new ArrayList<>();
                for (Integer row : crnRows) {
                    if (userSelectedRows.contains(row)) {
                        rows.add(row);
                    }
                }
            } else {
                rows = crnRows;
            }
            if (rows.isEmpty()) {
                continue;
            }
            // 查询该堆垛机范围内的库位
            QueryWrapper<LocMast> locQuery = new QueryWrapper<>();
            locQuery.in("row1", rows)
                    .between("bay1", param.getBayFrom(), param.getBayTo())
                    .between("lev1", param.getLevFrom(), param.getLevTo())
                    .eq("status", 1);
            List<LocMast> locs = locMastService.list(locQuery);
            if (locs.isEmpty()) {
                continue;
            }
            // 校验:该堆垛机范围内应恰好有1个在库托盘
            List<LocMast> fullLocs = new ArrayList<>();
            List<LocMast> emptyLocs = new ArrayList<>();
            int otherCount = 0;
            for (LocMast loc : locs) {
                if ("F".equals(loc.getLocSts())) {
                    fullLocs.add(loc);
                } else if ("O".equals(loc.getLocSts())) {
                    emptyLocs.add(loc);
                } else {
                    otherCount++;
                }
            }
            String crnLabel = crnOption.getCrnType().equals(SlaveType.Crn.name())
                    ? "堆垛机" + crnOption.getCrnNo() : "双工位堆垛机" + crnOption.getCrnNo();
            if (fullLocs.isEmpty()) {
                throw new CoolException(crnLabel + "范围内没有在库托盘");
            }
            if (fullLocs.size() > 1) {
                throw new CoolException(crnLabel + "范围内应恰好有1个在库托盘,当前有" + fullLocs.size() + "个");
            }
            if (emptyLocs.isEmpty()) {
                throw new CoolException(crnLabel + "范围内没有空库位,无法进行移转");
            }
            if (otherCount > 0) {
                throw new CoolException(crnLabel + "范围内有" + otherCount + "个库位状态异常,请检查");
            }
            // 生成移转计划:将托盘依次移到每个空库位
            LocMast sourceLoc = fullLocs.get(0);
            emptyLocs.sort(Comparator.comparingInt(LocMast::getBay1).thenComparingInt(LocMast::getLev1));
            String currentLocNo = sourceLoc.getLocNo();
            String barcode = sourceLoc.getBarcode();
            for (LocMast emptyLoc : emptyLocs) {
                WrkCyclePlanLoc planLoc = new WrkCyclePlanLoc();
                planLoc.setLocNo(currentLocNo);
                planLoc.setDestLocNo(emptyLoc.getLocNo());
                planLoc.setSeq(seq++);
                planLoc.setLocPlanSts(CyclePlanLocStatus.PENDING.id);
                planLoc.setBarcode(barcode);
                planLoc.setAppeTime(new Date());
                if (SlaveType.Crn.name().equals(crnOption.getCrnType())) {
                    planLoc.setCrnNo(crnOption.getCrnNo());
                } else {
                    planLoc.setDualCrnNo(crnOption.getCrnNo());
                }
                planLocs.add(planLoc);
                currentLocNo = emptyLoc.getLocNo();
            }
        }
        if (planLocs.isEmpty()) {
            throw new CoolException("所选范围内没有可移转的库位");
        }
        // 创建计划
        String planNo = "CYCLE_" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
        WrkCyclePlan plan = new WrkCyclePlan();
        plan.setPlanNo(planNo);
        plan.setCrnList(JSON.toJSONString(param.getCrnOptions()));
        plan.setPlanSts(CyclePlanStatus.NEW.id);
        plan.setTotalCount(planLocs.size());
        plan.setCompletedCount(0);
        plan.setAppeTime(new Date());
        this.save(plan);
        // 保存明细
        for (WrkCyclePlanLoc planLoc : planLocs) {
            planLoc.setPlanId(plan.getId());
        }
        wrkCyclePlanLocService.saveBatch(planLocs);
        return plan;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void startPlan(Long planId) {
        WrkCyclePlan plan = getById(planId);
        if (plan == null) {
            throw new CoolException("计划不存在");
        }
        if (plan.getPlanSts() != CyclePlanStatus.NEW.id && plan.getPlanSts() != CyclePlanStatus.PAUSED.id) {
            throw new CoolException("当前状态不允许启动");
        }
        plan.setPlanSts(CyclePlanStatus.RUNNING.id);
        plan.setModiTime(new Date());
        updateById(plan);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void pausePlan(Long planId) {
        WrkCyclePlan plan = getById(planId);
        if (plan == null) {
            throw new CoolException("计划不存在");
        }
        if (plan.getPlanSts() != CyclePlanStatus.RUNNING.id) {
            throw new CoolException("当前状态不允许暂停");
        }
        plan.setPlanSts(CyclePlanStatus.PAUSED.id);
        plan.setModiTime(new Date());
        updateById(plan);
    }
    @Override
    public void resumePlan(Long planId) {
        startPlan(planId);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void resetPlan(Long planId) {
        WrkCyclePlan plan = getById(planId);
        if (plan == null) {
            throw new CoolException("计划不存在");
        }
        // 取消活跃的WrkMast任务
        List<WrkCyclePlanLoc> activeLocs = wrkCyclePlanLocService.list(
                new QueryWrapper<WrkCyclePlanLoc>()
                        .eq("plan_id", planId)
                        .eq("loc_plan_sts", CyclePlanLocStatus.MOVING.id)
        );
        for (WrkCyclePlanLoc loc : activeLocs) {
            if (loc.getWrkNo() != null) {
                try {
                    cancelWrkMast(loc.getWrkNo());
                } catch (Exception e) {
                    log.error("跑库重置取消任务失败, wrkNo={}", loc.getWrkNo(), e);
                }
            }
        }
        // 重置所有明细(包括MOVING状态的)
        wrkCyclePlanLocService.update(null, new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<WrkCyclePlanLoc>()
                .eq("plan_id", planId)
                .set("loc_plan_sts", CyclePlanLocStatus.PENDING.id)
                .set("wrk_no", null)
                .set("modi_time", new Date()));
        // 重置计划
        plan.setPlanSts(CyclePlanStatus.NEW.id);
        plan.setCompletedCount(0);
        plan.setModiTime(new Date());
        updateById(plan);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void advancePlan(Long planId) {
        WrkCyclePlan plan = getById(planId);
        if (plan == null || plan.getPlanSts() != CyclePlanStatus.RUNNING.id) {
            return;
        }
        // 按堆垛机分组推进
        List<WrkCyclePlanLoc> pendingLocs = wrkCyclePlanLocService.list(
                new QueryWrapper<WrkCyclePlanLoc>()
                        .eq("plan_id", planId)
                        .eq("loc_plan_sts", CyclePlanLocStatus.PENDING.id)
                        .orderByAsc("seq")
        );
        if (pendingLocs.isEmpty()) {
            // 没有待执行的,检查是否全部完成
            long movingCount = wrkCyclePlanLocService.count(
                    new QueryWrapper<WrkCyclePlanLoc>()
                            .eq("plan_id", planId)
                            .eq("loc_plan_sts", CyclePlanLocStatus.MOVING.id)
            );
            if (movingCount == 0) {
                plan.setPlanSts(CyclePlanStatus.COMPLETED.id);
                plan.setModiTime(new Date());
                updateById(plan);
            }
            return;
        }
        // 按堆垛机分组,每组取第一个待执行的
        Map<String, List<WrkCyclePlanLoc>> groupedByCrn = pendingLocs.stream()
                .collect(Collectors.groupingBy(this::getCrnKey, LinkedHashMap::new, Collectors.toList()));
        for (Map.Entry<String, List<WrkCyclePlanLoc>> entry : groupedByCrn.entrySet()) {
            // 检查该堆垛机是否有正在执行的任务
            boolean hasActive = hasActiveTaskForCrn(planId, entry.getKey());
            if (hasActive) {
                continue;
            }
            // 提交第一个待执行的任务
            WrkCyclePlanLoc planLoc = entry.getValue().get(0);
            submitLocMoveTask(plan, planLoc);
        }
    }
    @Override
    public void onWrkMastComplete(Integer wrkNo) {
        try {
            WrkCyclePlanLoc planLoc = wrkCyclePlanLocService.getOne(
                    new QueryWrapper<WrkCyclePlanLoc>().eq("wrk_no", wrkNo)
            );
            if (planLoc == null) {
                return;
            }
            planLoc.setLocPlanSts(CyclePlanLocStatus.DONE.id);
            planLoc.setModiTime(new Date());
            wrkCyclePlanLocService.updateById(planLoc);
            // 原子递增 completedCount,避免并发丢失更新
            WrkCyclePlan plan = getById(planLoc.getPlanId());
            if (plan != null) {
                update(new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<WrkCyclePlan>()
                        .eq("id", plan.getId())
                        .setSql("completed_count = completed_count + 1"));
                // 重新读取判断是否全部完成
                plan = getById(plan.getId());
                if (plan != null && plan.getCompletedCount() >= plan.getTotalCount()) {
                    plan.setPlanSts(CyclePlanStatus.COMPLETED.id);
                    plan.setModiTime(new Date());
                    updateById(plan);
                }
            }
        } catch (Exception e) {
            log.error("跑库计划onWrkMastComplete处理异常, wrkNo={}", wrkNo, e);
        }
    }
    private void submitLocMoveTask(WrkCyclePlan plan, WrkCyclePlanLoc planLoc) {
        try {
            // 检测托盘实际位置:在该堆垛机控制范围内查找在库库位
            String actualLocNo = planLoc.getLocNo();
            List<Integer> crnRows = getCrnControlRows(
                    planLoc.getCrnNo() != null ? planLoc.getCrnNo() : planLoc.getDualCrnNo(),
                    planLoc.getCrnNo() != null ? SlaveType.Crn.name() : SlaveType.DualCrn.name()
            );
            LocMast actualLoc = locMastService.getOne(
                    new QueryWrapper<LocMast>()
                            .in("row1", crnRows)
                            .eq("loc_sts", String.valueOf(LocStsType.F))
                            .eq("status", 1)
                            .last("LIMIT 1")
            );
            if (actualLoc != null) {
                actualLocNo = actualLoc.getLocNo();
            }
            CreateLocMoveTaskParam param = new CreateLocMoveTaskParam();
            param.setSourceLocNo(actualLocNo);
            param.setLocNo(planLoc.getDestLocNo());
            WrkMast wrkMast = commonService.createLocMoveTaskReturnMast(param);
            // 记录实际移出库位
            planLoc.setLocNo(actualLocNo);
            planLoc.setWrkNo(wrkMast.getWrkNo());
            planLoc.setLocPlanSts(CyclePlanLocStatus.MOVING.id);
            planLoc.setModiTime(new Date());
            wrkCyclePlanLocService.updateById(planLoc);
        } catch (Exception e) {
            log.error("跑库计划提交移库任务失败, planId={}, locNo={}", plan.getId(), planLoc.getLocNo(), e);
        }
    }
    private boolean hasActiveTaskForCrn(Long planId, String crnKey) {
        QueryWrapper<WrkCyclePlanLoc> query = new QueryWrapper<WrkCyclePlanLoc>()
                .eq("plan_id", planId)
                .eq("loc_plan_sts", CyclePlanLocStatus.MOVING.id);
        List<WrkCyclePlanLoc> movingLocs = wrkCyclePlanLocService.list(query);
        for (WrkCyclePlanLoc loc : movingLocs) {
            if (crnKey.equals(getCrnKey(loc))) {
                return true;
            }
        }
        return false;
    }
    private String getCrnKey(WrkCyclePlanLoc loc) {
        if (loc.getCrnNo() != null) {
            return "Crn_" + loc.getCrnNo();
        } else if (loc.getDualCrnNo() != null) {
            return "DualCrn_" + loc.getDualCrnNo();
        }
        return "unknown";
    }
    private List<Integer> getCrnControlRows(Integer crnNo, String crnType) {
        if (SlaveType.Crn.name().equals(crnType)) {
            BasCrnp crnp = basCrnpService.getOne(new QueryWrapper<BasCrnp>().eq("crn_no", crnNo));
            if (crnp == null) {
                throw new CoolException("堆垛机" + crnNo + "不存在");
            }
            List<List<Integer>> rowGroups = crnp.getControlRows$();
            List<Integer> rows = new ArrayList<>();
            for (List<Integer> group : rowGroups) {
                rows.addAll(group);
            }
            return rows;
        } else if (SlaveType.DualCrn.name().equals(crnType)) {
            BasDualCrnp dualCrnp = basDualCrnpService.getOne(new QueryWrapper<BasDualCrnp>().eq("crn_no", crnNo));
            if (dualCrnp == null) {
                throw new CoolException("双工位堆垛机" + crnNo + "不存在");
            }
            List<List<Integer>> rowGroups = dualCrnp.getControlRows$();
            List<Integer> rows = new ArrayList<>();
            for (List<Integer> group : rowGroups) {
                rows.addAll(group);
            }
            return rows;
        }
        throw new CoolException("未知堆垛机类型:" + crnType);
    }
    private void checkCrnAvailable(Integer crnNo, String crnType) {
        // 检查是否有同堆垛机的运行中或暂停计划
        List<WrkCyclePlan> existingPlans = list(
                new QueryWrapper<WrkCyclePlan>()
                        .in("plan_sts", CyclePlanStatus.RUNNING.id, CyclePlanStatus.PAUSED.id)
        );
        for (WrkCyclePlan existingPlan : existingPlans) {
            List<CreateCyclePlanParam.CrnOption> crnOptions = JSON.parseArray(existingPlan.getCrnList(), CreateCyclePlanParam.CrnOption.class);
            if (crnOptions != null) {
                for (CreateCyclePlanParam.CrnOption opt : crnOptions) {
                    if (crnNo.equals(opt.getCrnNo()) && crnType.equals(opt.getCrnType())) {
                        throw new CoolException("堆垛机" + crnNo + "已有运行中或暂停的跑库计划(" + existingPlan.getPlanNo() + ")");
                    }
                }
            }
        }
    }
    private void cancelWrkMast(Integer wrkNo) {
        WrkMast wrkMast = wrkMastService.selectByWorkNo(wrkNo);
        if (wrkMast == null) {
            return;
        }
        wrkMastService.update(null, new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<WrkMast>()
                .eq("wrk_no", wrkNo)
                .set("mk", "taskForceCancel")
                .set("memo", "跑库计划重置")
                .set("modi_time", new Date()));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deletePlan(Long planId) {
        WrkCyclePlan plan = getById(planId);
        if (plan == null) {
            throw new CoolException("计划不存在");
        }
        if (plan.getPlanSts() == CyclePlanStatus.RUNNING.id) {
            throw new CoolException("运行中的计划不能删除,请先暂停或重置");
        }
        // 取消活跃任务
        List<WrkCyclePlanLoc> activeLocs = wrkCyclePlanLocService.list(
                new QueryWrapper<WrkCyclePlanLoc>()
                        .eq("plan_id", planId)
                        .eq("loc_plan_sts", CyclePlanLocStatus.MOVING.id)
        );
        for (WrkCyclePlanLoc loc : activeLocs) {
            if (loc.getWrkNo() != null) {
                try {
                    cancelWrkMast(loc.getWrkNo());
                } catch (Exception e) {
                    log.error("跑库删除取消任务失败, wrkNo={}", loc.getWrkNo(), e);
                }
            }
        }
        // 删除明细
        wrkCyclePlanLocService.remove(new QueryWrapper<WrkCyclePlanLoc>().eq("plan_id", planId));
        // 删除计划
        removeById(planId);
    }
}
src/main/java/com/zy/asrs/task/CyclePlanScheduler.java
New file
@@ -0,0 +1,46 @@
package com.zy.asrs.task;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.zy.asrs.entity.WrkCyclePlan;
import com.zy.asrs.service.WrkCyclePlanService;
import com.zy.asrs.mapper.WrkCyclePlanMapper;
import com.zy.core.enums.CyclePlanStatus;
import com.zy.core.task.MainProcessTaskSubmitter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
@Slf4j
public class CyclePlanScheduler {
    private static final String LANE_PREFIX = "cycle-plan-";
    @Autowired
    private WrkCyclePlanMapper wrkCyclePlanMapper;
    @Autowired
    private MainProcessTaskSubmitter mainProcessTaskSubmitter;
    @Autowired
    private WrkCyclePlanService wrkCyclePlanService;
    @Scheduled(cron = "0/1 * * * * ?")
    public void advanceRunningPlans() {
        List<WrkCyclePlan> plans = wrkCyclePlanMapper.selectList(
                new QueryWrapper<WrkCyclePlan>().eq("plan_sts", CyclePlanStatus.RUNNING.id)
        );
        for (WrkCyclePlan plan : plans) {
            boolean submitted = mainProcessTaskSubmitter.submitKeyedSerialTask(
                    LANE_PREFIX, plan.getId(),
                    "cycle-advance-" + plan.getId(), 0,
                    () -> wrkCyclePlanService.advancePlan(plan.getId())
            );
            if (!submitted) {
                log.error("CyclePlanScheduler提交失败, planId={}", plan.getId());
            }
        }
    }
}
src/main/java/com/zy/asrs/task/WrkMastFinalizeProcessor.java
@@ -11,6 +11,7 @@
import com.zy.asrs.service.LocMastService;
import com.zy.asrs.service.WrkAnalysisService;
import com.zy.asrs.service.WrkMastLogService;
import com.zy.asrs.service.WrkCyclePlanService;
import com.zy.asrs.service.WrkMastService;
import com.zy.asrs.utils.NotifyUtils;
import com.zy.common.utils.RedisUtil;
@@ -47,6 +48,7 @@
    private final StationOperateProcessUtils stationOperateProcessUtils;
    private final BasStationService basStationService;
    private final RedisUtil redisUtil;
    private final WrkCyclePlanService wrkCyclePlanService;
    public WrkMastFinalizeProcessor(WrkMastService wrkMastService,
                                    WrkMastLogService wrkMastLogService,
@@ -55,7 +57,8 @@
                                    NotifyUtils notifyUtils,
                                    StationOperateProcessUtils stationOperateProcessUtils,
                                    BasStationService basStationService,
                                    RedisUtil redisUtil) {
                                    RedisUtil redisUtil,
                                    WrkCyclePlanService wrkCyclePlanService) {
        this.wrkMastService = wrkMastService;
        this.wrkMastLogService = wrkMastLogService;
        this.wrkAnalysisService = wrkAnalysisService;
@@ -64,6 +67,7 @@
        this.stationOperateProcessUtils = stationOperateProcessUtils;
        this.basStationService = basStationService;
        this.redisUtil = redisUtil;
        this.wrkCyclePlanService = wrkCyclePlanService;
    }
    @Transactional(rollbackFor = Exception.class)
@@ -204,6 +208,8 @@
            }
            notifyUtils.notify("task", 1, String.valueOf(wrkMast.getWrkNo()), wrkMast.getWmsWrkNo(), NotifyMsgType.TASK_COMPLETE, JSON.toJSONString(wrkMast));
            wrkCyclePlanService.onWrkMastComplete(wrkNo);
        });
    }
src/main/java/com/zy/common/service/CommonService.java
@@ -415,6 +415,86 @@
        return true;
    }
    //移库任务(返回WrkMast,供跑库等场景获取工作号)
    public WrkMast createLocMoveTaskReturnMast(CreateLocMoveTaskParam param) {
        Date now = new Date();
        LocMast sourceLocMast = locMastService.queryByLoc(param.getSourceLocNo());
        if (null == sourceLocMast) {
            throw new CoolException(param.getSourceLocNo() + "源库位不存在");
        }
        if (!sourceLocMast.getLocSts().equals("F")) {
            throw new CoolException(sourceLocMast.getLocNo() + "源库位不处于在库状态");
        }
        LocMast locMast = locMastService.queryByLoc(param.getLocNo());
        if (null == locMast) {
            throw new CoolException(param.getLocNo() + "目标库位不存在");
        }
        if (!locMast.getLocSts().equals("O")) {
            throw new CoolException(locMast.getLocNo() + "目标库位不处于空库状态");
        }
        double ioPri = 800D;
        if (param.getTaskPri() != null) {
            ioPri = param.getTaskPri().doubleValue();
        }
        FindCrnNoResult sourceCrnResult = this.findCrnNoByLocNo(sourceLocMast.getLocNo());
        if (sourceCrnResult == null) {
            throw new CoolException("未找到对应堆垛机");
        }
        FindCrnNoResult targetCrnResult = this.findCrnNoByLocNo(locMast.getLocNo());
        if (targetCrnResult == null) {
            throw new CoolException("未找到对应堆垛机");
        }
        if (!sourceCrnResult.getCrnNo().equals(targetCrnResult.getCrnNo())) {
            throw new CoolException("源库位和目标库位不在同一巷道");
        }
        int workNo = getWorkNo(WrkIoType.LOC_MOVE.id);
        WrkMast wrkMast = new WrkMast();
        wrkMast.setWrkNo(workNo);
        wrkMast.setIoTime(now);
        wrkMast.setWrkSts(WrkStsType.NEW_LOC_MOVE.sts);
        wrkMast.setIoType(WrkIoType.LOC_MOVE.id);
        wrkMast.setIoPri(ioPri);
        wrkMast.setSourceLocNo(param.getSourceLocNo());
        wrkMast.setLocNo(param.getLocNo());
        wrkMast.setWmsWrkNo(param.getTaskNo());
        wrkMast.setBarcode(sourceLocMast.getBarcode());
        wrkMast.setAppeTime(now);
        wrkMast.setModiTime(now);
        if (targetCrnResult.getCrnType().equals(SlaveType.Crn)) {
            wrkMast.setCrnNo(targetCrnResult.getCrnNo());
        } else if (targetCrnResult.getCrnType().equals(SlaveType.DualCrn)) {
            wrkMast.setDualCrnNo(targetCrnResult.getCrnNo());
        } else {
            throw new CoolException("未知设备类型");
        }
        boolean res = wrkMastService.save(wrkMast);
        if (!res) {
            News.error("移库任务 --- 保存工作档失败!");
            throw new CoolException("保存工作档失败");
        }
        wrkAnalysisService.initForTask(wrkMast);
        sourceLocMast.setLocSts("R");
        sourceLocMast.setModiTime(new Date());
        locMastService.updateById(sourceLocMast);
        locMast.setLocSts("S");
        locMast.setModiTime(new Date());
        locMastService.updateById(locMast);
        return wrkMast;
    }
    //入库任务
    public WrkMast createInTask(CreateInTaskParam param) {
        Date now = new Date();
src/main/java/com/zy/core/enums/CyclePlanLocStatus.java
New file
@@ -0,0 +1,33 @@
package com.zy.core.enums;
import com.core.common.Cools;
public enum CyclePlanLocStatus {
    PENDING(0, "待执行"),
    MOVING(1, "执行中"),
    DONE(2, "已完成"),
    SKIPPED(-1, "跳过"),
    ;
    CyclePlanLocStatus(int id, String desc) {
        this.id = id;
        this.desc = desc;
    }
    public int id;
    public String desc;
    public static CyclePlanLocStatus get(int id) {
        if (Cools.isEmpty(id)) {
            return null;
        }
        for (CyclePlanLocStatus value : CyclePlanLocStatus.values()) {
            if (value.id == id) {
                return value;
            }
        }
        return null;
    }
}
src/main/java/com/zy/core/enums/CyclePlanStatus.java
New file
@@ -0,0 +1,46 @@
package com.zy.core.enums;
import com.core.common.Cools;
public enum CyclePlanStatus {
    NEW(0, "新建"),
    RUNNING(1, "运行中"),
    PAUSED(2, "暂停"),
    COMPLETED(3, "已完成"),
    CANCELLED(4, "已取消"),
    ;
    CyclePlanStatus(int id, String desc) {
        this.id = id;
        this.desc = desc;
    }
    public int id;
    public String desc;
    public static CyclePlanStatus get(int id) {
        if (Cools.isEmpty(id)) {
            return null;
        }
        for (CyclePlanStatus value : CyclePlanStatus.values()) {
            if (value.id == id) {
                return value;
            }
        }
        return null;
    }
    public static CyclePlanStatus get(String desc) {
        if (Cools.isEmpty(desc)) {
            return null;
        }
        for (CyclePlanStatus value : CyclePlanStatus.values()) {
            if (value.desc.equals(desc)) {
                return value;
            }
        }
        return null;
    }
}
src/main/resources/application.yml
@@ -1,6 +1,6 @@
# 系统版本信息
app:
  version: 3.0.1.8
  version: 3.0.1.9
  version-type: prd  # prd 或 dev
  i18n:
    default-locale: zh-CN
src/main/resources/sql/20260505_add_wrk_cycle_plan_menu.sql
New file
@@ -0,0 +1,72 @@
-- 将 跑库管理 菜单挂载到:作业流程
-- 说明:执行本脚本后,请在"角色授权"里给对应角色勾选新菜单和"查看"权限。
SET @wrk_cycle_plan_parent_id := (
  SELECT id
  FROM sys_resource
  WHERE code = 'workFlow' AND level = 1
  ORDER BY id
  LIMIT 1
);
SET @wrk_cycle_plan_sort := COALESCE(
  (
    SELECT MAX(COALESCE(sort, 0)) + 1
    FROM sys_resource
    WHERE resource_id = @wrk_cycle_plan_parent_id
      AND level = 2
  ),
  1
);
INSERT INTO sys_resource(code, name, resource_id, level, sort, status)
SELECT 'wrkCyclePlan/wrkCyclePlan.html', '跑库管理', @wrk_cycle_plan_parent_id, 2, @wrk_cycle_plan_sort, 1
FROM dual
WHERE @wrk_cycle_plan_parent_id IS NOT NULL
  AND NOT EXISTS (
    SELECT 1
    FROM sys_resource
    WHERE code = 'wrkCyclePlan/wrkCyclePlan.html' AND level = 2
  );
UPDATE sys_resource
SET name = '跑库管理',
    resource_id = @wrk_cycle_plan_parent_id,
    level = 2,
    sort = @wrk_cycle_plan_sort,
    status = 1
WHERE code = 'wrkCyclePlan/wrkCyclePlan.html' AND level = 2;
SET @wrk_cycle_plan_id := (
  SELECT id
  FROM sys_resource
  WHERE code = 'wrkCyclePlan/wrkCyclePlan.html' AND level = 2
  ORDER BY id
  LIMIT 1
);
INSERT INTO sys_resource(code, name, resource_id, level, sort, status)
SELECT 'wrkCyclePlan/wrkCyclePlan.html#view', '查看', @wrk_cycle_plan_id, 3, 1, 1
FROM dual
WHERE @wrk_cycle_plan_id IS NOT NULL
  AND NOT EXISTS (
    SELECT 1
    FROM sys_resource
    WHERE code = 'wrkCyclePlan/wrkCyclePlan.html#view' AND level = 3
  );
UPDATE sys_resource
SET name = '查看',
    resource_id = @wrk_cycle_plan_id,
    level = 3,
    sort = 1,
    status = 1
WHERE code = 'wrkCyclePlan/wrkCyclePlan.html#view' AND level = 3;
SELECT id, code, name, resource_id, level, sort, status
FROM sys_resource
WHERE code IN (
  'wrkCyclePlan/wrkCyclePlan.html',
  'wrkCyclePlan/wrkCyclePlan.html#view'
)
ORDER BY level, sort, id;
src/main/resources/sql/20260505_create_wrk_cycle_plan.sql
New file
@@ -0,0 +1,33 @@
-- 跑库计划表
CREATE TABLE IF NOT EXISTS asr_wrk_cycle_plan (
    id              BIGINT AUTO_INCREMENT PRIMARY KEY,
    plan_no         VARCHAR(50)  NOT NULL COMMENT '计划编号',
    crn_list        TEXT         NOT NULL COMMENT '堆垛机列表JSON',
    plan_sts        INT          NOT NULL DEFAULT 0 COMMENT '0=新建 1=运行中 2=暂停 3=已完成 4=已取消',
    total_count     INT          NOT NULL DEFAULT 0 COMMENT '总库位数',
    completed_count INT          NOT NULL DEFAULT 0 COMMENT '已完成数',
    memo            VARCHAR(255) DEFAULT NULL,
    appe_user       BIGINT       DEFAULT NULL,
    appe_time       DATETIME     DEFAULT NULL,
    modi_user       BIGINT       DEFAULT NULL,
    modi_time       DATETIME     DEFAULT NULL,
    UNIQUE KEY uk_plan_no (plan_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跑库计划';
-- 跑库计划库位明细表
CREATE TABLE IF NOT EXISTS asr_wrk_cycle_plan_loc (
    id            BIGINT AUTO_INCREMENT PRIMARY KEY,
    plan_id       BIGINT       NOT NULL COMMENT '计划ID',
    loc_no        VARCHAR(50)  NOT NULL COMMENT '源库位号',
    dest_loc_no   VARCHAR(50)  NOT NULL COMMENT '目标库位号',
    seq           INT          NOT NULL COMMENT '执行序号',
    crn_no        INT          DEFAULT NULL COMMENT '堆垛机号',
    dual_crn_no   INT          DEFAULT NULL COMMENT '双工位堆垛机号',
    loc_plan_sts  INT          NOT NULL DEFAULT 0 COMMENT '0=待执行 1=执行中 2=已完成 -1=跳过',
    wrk_no        INT          DEFAULT NULL COMMENT '工作号',
    barcode       VARCHAR(100) DEFAULT NULL COMMENT '托盘码快照',
    appe_time     DATETIME     DEFAULT NULL,
    modi_time     DATETIME     DEFAULT NULL,
    KEY idx_plan_id (plan_id),
    KEY idx_plan_seq (plan_id, seq)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跑库计划库位明细';
src/main/webapp/static/js/wrkCyclePlan/wrkCyclePlan.js
New file
@@ -0,0 +1,259 @@
(function () {
    var token = localStorage.getItem("token") || "";
    function ajax(method, url, data) {
        return $.ajax({
            method: method,
            url: baseUrl + url,
            contentType: "application/json",
            headers: { token: token },
            data: data ? JSON.stringify(data) : undefined
        });
    }
    function parseCrnListJson(crnListStr) {
        try {
            return JSON.parse(crnListStr) || [];
        } catch (e) {
            return [];
        }
    }
    new Vue({
        el: "#app",
        data: function () {
            return {
                creating: false,
                rowsLoading: false,
                craneOptions: [],
                availableRows: [],
                form: {
                    crnOptions: [],
                    rows: [],
                    bayFrom: 1,
                    bayTo: 10,
                    levFrom: 1,
                    levTo: 5
                },
                planList: [],
                planPage: { curr: 1, limit: 10, total: 0 },
                selectedPlan: null,
                locList: [],
                locPage: { curr: 1, limit: 20, total: 0 },
                refreshTimer: null
            };
        },
        mounted: function () {
            this.loadCraneOptions();
            this.loadPlans();
        },
        beforeDestroy: function () {
            this.stopAutoRefresh();
        },
        methods: {
            loadCraneOptions: function () {
                var self = this;
                ajax("GET", "/wrkCyclePlan/craneOptions/auth").done(function (res) {
                    if (res.code === 200) {
                        self.craneOptions = res.data || [];
                    }
                });
            },
            loadPlans: function () {
                var self = this;
                ajax("GET", "/wrkCyclePlan/list/auth?curr=" + self.planPage.curr + "&limit=" + self.planPage.limit).done(function (res) {
                    if (res.code === 200 && res.data) {
                        self.planList = res.data.records || [];
                        self.planPage.total = res.data.total || 0;
                    }
                });
            },
            loadLocs: function () {
                if (!this.selectedPlan) return;
                var self = this;
                ajax("GET", "/wrkCyclePlan/locList/auth?planId=" + self.selectedPlan.id +
                    "&curr=" + self.locPage.curr + "&limit=" + self.locPage.limit).done(function (res) {
                    if (res.code === 200 && res.data) {
                        self.locList = res.data.records || [];
                        self.locPage.total = res.data.total || 0;
                    }
                });
            },
            handleCrnChange: function (val) {
                this.form.rows = [];
                this.availableRows = [];
                if (!val || !val.length) return;
                var self = this;
                var crnOpts = val.map(function (item) {
                    var parts = item.split("|");
                    return { crnNo: parseInt(parts[0]), crnType: parts[1] };
                });
                self.rowsLoading = true;
                ajax("POST", "/wrkCyclePlan/availableRows/auth", crnOpts).done(function (res) {
                    if (res.code === 200) {
                        self.availableRows = res.data || [];
                    }
                }).always(function () {
                    self.rowsLoading = false;
                });
            },
            handleCreate: function () {
                if (!this.form.crnOptions.length) {
                    this.$message.warning("请选择堆垛机");
                    return;
                }
                var crnOpts = this.form.crnOptions.map(function (item) {
                    var parts = item.split("|");
                    return { crnNo: parseInt(parts[0]), crnType: parts[1] };
                });
                var param = {
                    crnOptions: crnOpts,
                    rows: this.form.rows.length ? this.form.rows : null,
                    bayFrom: this.form.bayFrom,
                    bayTo: this.form.bayTo,
                    levFrom: this.form.levFrom,
                    levTo: this.form.levTo
                };
                var self = this;
                self.creating = true;
                ajax("POST", "/wrkCyclePlan/create/auth", param).done(function (res) {
                    if (res.code === 200) {
                        self.$message.success("创建成功");
                        self.planPage.curr = 1;
                        self.loadPlans();
                    } else {
                        self.$message.error(res.msg || "创建失败");
                    }
                }).always(function () {
                    self.creating = false;
                });
            },
            handleStart: function (plan) {
                var self = this;
                ajax("POST", "/wrkCyclePlan/start/auth", { planId: plan.id }).done(function (res) {
                    if (res.code === 200) {
                        self.$message.success("已启动");
                        self.loadPlans();
                        self.startAutoRefresh();
                    } else {
                        self.$message.error(res.msg || "操作失败");
                    }
                });
            },
            handlePause: function (plan) {
                var self = this;
                ajax("POST", "/wrkCyclePlan/pause/auth", { planId: plan.id }).done(function (res) {
                    if (res.code === 200) {
                        self.$message.success("已暂停");
                        self.loadPlans();
                        self.stopAutoRefresh();
                    } else {
                        self.$message.error(res.msg || "操作失败");
                    }
                });
            },
            handleReset: function (plan) {
                var self = this;
                self.$confirm("确定重置该计划?已执行的任务将被取消。", "提示", {
                    type: "warning"
                }).then(function () {
                    ajax("POST", "/wrkCyclePlan/reset/auth", { planId: plan.id }).done(function (res) {
                        if (res.code === 200) {
                            self.$message.success("已重置");
                            self.loadPlans();
                            if (self.selectedPlan && self.selectedPlan.id === plan.id) {
                                self.loadLocs();
                            }
                        } else {
                            self.$message.error(res.msg || "操作失败");
                        }
                    });
                }).catch(function () {});
            },
            handleDelete: function (plan) {
                var self = this;
                self.$confirm("确定删除该计划?此操作不可恢复。", "提示", {
                    type: "warning"
                }).then(function () {
                    ajax("POST", "/wrkCyclePlan/delete/auth", { planId: plan.id }).done(function (res) {
                        if (res.code === 200) {
                            self.$message.success("已删除");
                            if (self.selectedPlan && self.selectedPlan.id === plan.id) {
                                self.selectedPlan = null;
                                self.locList = [];
                            }
                            self.loadPlans();
                        } else {
                            self.$message.error(res.msg || "删除失败");
                        }
                    });
                }).catch(function () {});
            },
            handlePlanClick: function (row) {
                this.selectedPlan = row;
                this.locPage.curr = 1;
                this.loadLocs();
            },
            handlePlanPageChange: function (page) {
                this.planPage.curr = page;
                this.loadPlans();
            },
            handleLocPageChange: function (page) {
                this.locPage.curr = page;
                this.loadLocs();
            },
            parseCrnList: function (crnListStr) {
                var list = parseCrnListJson(crnListStr);
                return list.map(function (item) {
                    var label = item.crnType === "Crn" ? "堆垛机" + item.crnNo : "双工位" + item.crnNo;
                    return { crnNo: item.crnNo, crnType: item.crnType, label: label };
                });
            },
            planPercent: function (plan) {
                if (!plan.totalCount) return 0;
                return Math.round(plan.completedCount / plan.totalCount * 100);
            },
            planStsType: function (sts) {
                var map = { 0: "info", 1: "success", 2: "warning", 3: "success", 4: "danger" };
                return map[sts] != null ? map[sts] : "info";
            },
            locStsType: function (sts) {
                var map = { 0: "info", 1: "warning", 2: "success", "-1": "danger" };
                return map[sts] || "info";
            },
            formatDate: function (val) {
                if (!val) return "";
                var d = new Date(val);
                if (isNaN(d.getTime())) return val;
                var pad = function (n) { return n < 10 ? "0" + n : String(n); };
                return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
            },
            startAutoRefresh: function () {
                this.stopAutoRefresh();
                var self = this;
                self.refreshTimer = setInterval(function () {
                    self.loadPlans();
                    if (self.selectedPlan) {
                        self.loadLocs();
                    }
                }, 2000);
            },
            stopAutoRefresh: function () {
                if (this.refreshTimer) {
                    clearInterval(this.refreshTimer);
                    this.refreshTimer = null;
                }
            }
        },
        watch: {
            planList: function (list) {
                var hasRunning = list.some(function (p) { return p.planSts === 1; });
                if (hasRunning) {
                    this.startAutoRefresh();
                } else {
                    this.stopAutoRefresh();
                }
            }
        }
    });
})();
src/main/webapp/views/wrkCyclePlan/wrkCyclePlan.html
New file
@@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>跑库管理</title>
    <meta name="renderer" content="webkit">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
    <link rel="stylesheet" href="../../static/vue/element/element.css">
    <link rel="stylesheet" href="../../static/css/cool.css">
    <style>
        :root {
            --card-bg: rgba(255, 255, 255, 0.94);
            --card-border: rgba(216, 226, 238, 0.95);
            --text-main: #243447;
        }
        [v-cloak] { display: none; }
        html, body {
            margin: 0; min-height: 100%;
            color: var(--text-main);
            font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif;
            background: linear-gradient(180deg, #f2f6fb 0%, #f8fafc 100%);
        }
        .page-shell { max-width: 1700px; margin: 0 auto; padding: 14px; box-sizing: border-box; }
        .card-shell {
            border-radius: 24px; border: 1px solid var(--card-border);
            background: var(--card-bg);
            box-shadow: 0 16px 32px rgba(44, 67, 96, 0.08);
            overflow: hidden; margin-bottom: 14px;
        }
        .card-title {
            padding: 16px 20px 12px; font-size: 16px; font-weight: 700;
            border-bottom: 1px solid rgba(222, 230, 239, 0.92);
        }
        .card-body { padding: 16px 20px; }
        .form-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
        .form-label { font-size: 13px; color: #53677d; white-space: nowrap; }
        .table-wrap { padding: 10px 16px; }
        .table-shell {
            border-radius: 20px; overflow: hidden;
            border: 1px solid rgba(217, 227, 238, 0.98);
            background: rgba(255, 255, 255, 0.95);
        }
        .table-shell .el-table { border-radius: 20px; overflow: hidden; }
        .table-shell .el-table th { background: #f7fafc; color: #53677d; font-weight: 700; }
        .pager-bar { padding: 0 16px 16px; display: flex; justify-content: flex-end; }
        .action-btns { display: flex; gap: 6px; }
        .progress-cell { display: flex; align-items: center; gap: 4px; }
        .progress-text { font-size: 11px; color: #53677d; white-space: nowrap; flex-shrink: 0; }
        .progress-cell .el-progress { min-width: 0; }
        .plan-detail { padding: 0 16px 16px; }
    </style>
</head>
<body>
<div id="app" class="page-shell" v-cloak>
    <!-- 创建计划 -->
    <section class="card-shell">
        <div class="card-title">创建跑库计划</div>
        <div class="card-body">
            <div class="form-row">
                <span class="form-label">堆垛机:</span>
                <el-select v-model="form.crnOptions" multiple filterable size="small" placeholder="选择堆垛机" style="width: 360px;" @change="handleCrnChange">
                    <el-option v-for="item in craneOptions" :key="item.crnNo + '-' + item.crnType"
                               :label="item.label" :value="item.crnNo + '|' + item.crnType">
                    </el-option>
                </el-select>
                <span class="form-label">排:</span>
                <el-select v-model="form.rows" multiple filterable size="small" placeholder="选择排号(不选则全部)" style="width: 260px;" :loading="rowsLoading">
                    <el-option v-for="r in availableRows" :key="r" :label="'第' + r + '排'" :value="r"></el-option>
                </el-select>
                <span class="form-label">列范围:</span>
                <el-input-number v-model="form.bayFrom" size="small" :min="1" controls-position="right" style="width: 100px;"></el-input-number>
                <span class="form-label">~</span>
                <el-input-number v-model="form.bayTo" size="small" :min="1" controls-position="right" style="width: 100px;"></el-input-number>
                <span class="form-label">层范围:</span>
                <el-input-number v-model="form.levFrom" size="small" :min="1" controls-position="right" style="width: 100px;"></el-input-number>
                <span class="form-label">~</span>
                <el-input-number v-model="form.levTo" size="small" :min="1" controls-position="right" style="width: 100px;"></el-input-number>
                <el-button size="small" type="primary" icon="el-icon-plus" :loading="creating" @click="handleCreate">创建计划</el-button>
            </div>
        </div>
    </section>
    <!-- 计划列表 -->
    <section class="card-shell">
        <div class="card-title">
            跑库计划列表
            <el-button size="mini" style="float:right;" icon="el-icon-refresh" @click="loadPlans">刷新</el-button>
        </div>
        <div class="table-wrap">
            <div class="table-shell">
                <el-table :data="planList" border stripe size="small" @row-click="handlePlanClick"
                          row-key="id" :highlight-current-row="true">
                    <el-table-column prop="planNo" label="计划编号" min-width="200"></el-table-column>
                    <el-table-column label="堆垛机" min-width="200">
                        <template slot-scope="scope">
                            <el-tag v-for="c in parseCrnList(scope.row.crnList)" :key="c.crnNo + c.crnType"
                                    size="mini" style="margin-right:4px;">{{ c.label || (c.crnType + c.crnNo) }}</el-tag>
                        </template>
                    </el-table-column>
                    <el-table-column label="状态" width="100" align="center">
                        <template slot-scope="scope">
                            <el-tag :type="planStsType(scope.row.planSts)" size="mini">{{ scope.row['planSts$'] }}</el-tag>
                        </template>
                    </el-table-column>
                    <el-table-column label="进度" width="180">
                        <template slot-scope="scope">
                            <div class="progress-cell">
                                <el-progress :percentage="planPercent(scope.row)" :stroke-width="8" :show-text="false" style="flex:1;"></el-progress>
                                <span class="progress-text">{{ scope.row.completedCount }}/{{ scope.row.totalCount }} ({{ planPercent(scope.row) }}%)</span>
                            </div>
                        </template>
                    </el-table-column>
                    <el-table-column label="创建时间" width="160">
                        <template slot-scope="scope">{{ formatDate(scope.row.appeTime) }}</template>
                    </el-table-column>
                    <el-table-column label="操作" width="240" align="center">
                        <template slot-scope="scope">
                            <div class="action-btns">
                                <el-button v-if="scope.row.planSts === 0 || scope.row.planSts === 2"
                                           size="mini" type="success" @click.stop="handleStart(scope.row)">启动</el-button>
                                <el-button v-if="scope.row.planSts === 1"
                                           size="mini" type="warning" @click.stop="handlePause(scope.row)">暂停</el-button>
                                <el-button v-if="scope.row.planSts !== 3 && scope.row.planSts !== 4"
                                           size="mini" type="danger" @click.stop="handleReset(scope.row)">重置</el-button>
                                <el-button v-if="scope.row.planSts !== 1"
                                           size="mini" type="danger" plain @click.stop="handleDelete(scope.row)">删除</el-button>
                            </div>
                        </template>
                    </el-table-column>
                </el-table>
            </div>
        </div>
        <div class="pager-bar">
            <el-pagination small background layout="total, prev, pager, next"
                           :current-page="planPage.curr" :page-size="planPage.limit" :total="planPage.total"
                           @current-change="handlePlanPageChange">
            </el-pagination>
        </div>
    </section>
    <!-- 库位明细 -->
    <section v-if="selectedPlan" class="card-shell">
        <div class="card-title">
            库位明细 - {{ selectedPlan.planNo }}
            <el-button size="mini" style="float:right;" icon="el-icon-refresh" @click="loadLocs">刷新</el-button>
        </div>
        <div class="plan-detail">
            <div class="table-shell">
                <el-table :data="locList" border stripe size="small">
                    <el-table-column prop="seq" label="序号" width="70" align="center"></el-table-column>
                    <el-table-column prop="locNo" label="源库位" min-width="140"></el-table-column>
                    <el-table-column prop="destLocNo" label="目标库位" min-width="140"></el-table-column>
                    <el-table-column label="堆垛机" width="120" align="center">
                        <template slot-scope="scope">
                            <span v-if="scope.row.crnNo">Crn-{{ scope.row.crnNo }}</span>
                            <span v-else-if="scope.row.dualCrnNo">Dual-{{ scope.row.dualCrnNo }}</span>
                        </template>
                    </el-table-column>
                    <el-table-column prop="barcode" label="托盘码" min-width="150"></el-table-column>
                    <el-table-column label="状态" width="100" align="center">
                        <template slot-scope="scope">
                            <el-tag :type="locStsType(scope.row.locPlanSts)" size="mini">{{ scope.row['locPlanSts$'] }}</el-tag>
                        </template>
                    </el-table-column>
                    <el-table-column prop="wrkNo" label="任务号" width="100" align="center"></el-table-column>
                </el-table>
            </div>
            <div class="pager-bar">
                <el-pagination small background layout="total, prev, pager, next"
                               :current-page="locPage.curr" :page-size="locPage.limit" :total="locPage.total"
                               @current-change="handleLocPageChange">
                </el-pagination>
            </div>
        </div>
    </section>
</div>
</body>
<script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../../static/js/common.js?v=20260309_i18n_fix1"></script>
<script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../../static/vue/element/element.js"></script>
<script type="text/javascript" src="../../static/js/wrkCyclePlan/wrkCyclePlan.js"></script>
</html>