自动化立体仓库 - WMS系统
zwl
2 天以前 b0877a3275ed5bc96fb80f84949904e149946cf2
将erp下发直接生成任务改成先生成订单再出库
4个文件已修改
510 ■■■■ 已修改文件
src/main/java/com/zy/asrs/controller/OpenController.java 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/service/OpenService.java 25 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/service/impl/ExternalTaskFacadeServiceImpl.java 54 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/service/impl/OpenServiceImpl.java 310 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/controller/OpenController.java
@@ -526,35 +526,43 @@
    /**
     * 7.11 出库通知单(传递有序无序规则)。
     *
     * stationId <= 600:沿用原实时任务模式,接口校验通过后直接生成 WrkMast/WrkDetl。
     * stationId > 600:进入“先建出库订单、后续定时生成任务”的延迟模式。
     * ERP 出库通知统一先落出库订单,不再由接口线程直接创建 WrkMast/WrkDetl。
     *
     * 延迟模式的关键点:
     * 关键规则:
     * 1. orderId 作为出库订单号落到 man_order_pakout.order_no。
     * 2. entryWmsCode 作为进仓编号保存到订单明细,后续生成任务时强制作为 WrkMast.batchSeq。
     * 3. 建单阶段只校验库存和满库位,不锁库位、不改 loc_sts,避免提前占用设备任务资源。
     * 2. stationId > 600 时必须传 entryWmsCode,后续按 entryWmsCode 作为进仓编号分批生成任务。
     * 3. stationId <= 600 时允许 entryWmsCode 为空,后续生成任务时使用 orderId 作为批次键。
     * 4. 建单阶段只校验库存和满库位,不锁库位、不改 loc_sts,真正占库仍由定时生成任务时处理。
     *
     * 为什么这里不再直接调用 outOrderBatch:
     * - 直接建 WrkMast 会马上把库位从 F 改为 R,ERP 重发、人工中止、WCS 取消后的补偿都很难统一。
     * - 先落订单明细后,未生成任务的明细可以删除重下发;已生成任务的明细由取消逻辑回滚 work_qty。
     * - 低站点和高站点走同一条订单链路后,pakoutOrderPause/execute 才能同时控制“未生成”和“已生成”的任务。
     */
    @PostMapping("/outOrder")
    public synchronized R outOrder(@RequestBody ArrayList<OutTaskParam> params, HttpServletRequest request) {
        if (Cools.isEmpty(params)) {
            return R.error("请求参数不能为空");
        }
        int count = params.size();
        log.info("[outOrder] cache: {}", JSON.toJSONString(params));
        request.setAttribute("cache", params);
        // stationId <= 600 的原始实时出库任务分组,后面直接传给 outOrderBatch。
        Map<String, List<OutTaskParam>> linesByBatchSeq = new LinkedHashMap<>();
        // 统一做有序/无序校验的分组:
        // 低站点按 orderId + batchSeq;高站点按 orderId + entryWmsCode。
        // 高站点后续会把 entryWmsCode 转成任务 batchSeq,因此这里先按同一维度校验 seq。
        // 统一做有序/无序校验的分组。
        //
        // 注意:这里的分组 key 不是 ERP 原始 batchSeq,而是“未来生成 WrkMast.batchSeq 的值”。
        // 这样可以在接口入口就校验最终任务批次里的 seq 是否连续,避免订单已经落库后定时器才失败。
        //
        // 批次键规则:
        // - 高站点按 orderId + entryWmsCode;
        // - 低站点按 orderId + orderId。
        Map<String, List<OutTaskParam>> linesByValidateKey = new LinkedHashMap<>();
        // stationId > 600 的参数只建出库订单,定时器再按进仓编号逐批生成任务。
        List<OutTaskParam> pendingOrderParams = new ArrayList<>();
        for (OutTaskParam outTaskParam : params) {
            if (Cools.isEmpty(outTaskParam) || Cools.isEmpty(outTaskParam.getOrderId())) {
                return R.error("出库单号不能为空");
            }
            if (Cools.isEmpty(outTaskParam.getBatchSeq())) {
                // batchSeq 仍保存 ERP 字段;未传时用 orderId 补齐,便于明细追溯。
                // 低站点真正生成任务时不使用该字段作为批次键,而是固定使用 orderId。
                outTaskParam.setBatchSeq(outTaskParam.getOrderId());
            }
            if (Cools.isEmpty(outTaskParam.getStationId())) {
@@ -569,24 +577,26 @@
            if (stationId > 600) {
                // 高站点任务必须带进仓编号;这是定时生成任务时的批次边界,
                // 也是 OutboundBatchSeqReleaseGuard 判断能否放行下一进仓编号的依据。
                // 低站点不做该限制,低站点没有进仓编号时会统一使用 orderId 作为批次键。
                if (Cools.isEmpty(outTaskParam.getEntryWmsCode())) {
                    return R.error("托盘「" + outTaskParam.getPalletId() + "」进仓编号不能为空");
                }
                pendingOrderParams.add(outTaskParam);
                linesByValidateKey.computeIfAbsent(outTaskParam.getOrderId() + "#" + outTaskParam.getEntryWmsCode(), k -> new ArrayList<>()).add(outTaskParam);
            } else {
                linesByBatchSeq.computeIfAbsent(outTaskParam.getBatchSeq(), k -> new ArrayList<>()).add(outTaskParam);
                linesByValidateKey.computeIfAbsent(buildOutOrderBatchKey(outTaskParam), k -> new ArrayList<>()).add(outTaskParam);
            }
            linesByValidateKey.computeIfAbsent(buildOutOrderValidateKey(outTaskParam), k -> new ArrayList<>()).add(outTaskParam);
        }
        // 仍保留原接口的有序/无序规则。高站点虽然暂不生成任务,也要在建单前保证
        // 同一进仓编号内的明细顺序合法,否则后续定时生成任务时才失败会更难追溯。
        // 仍保留原接口的有序/无序规则:
        // - seq=0 表示无序,同批次所有明细都必须为 0;
        // - seq>0 表示有序,同批次必须从 1 开始连续;
        // - seq=0 和 seq>0 不能混用。
        //
        // 所有明细虽然暂不生成任务,也要在建单前保证同一任务批次内的明细顺序合法,
        // 否则后续定时生成任务时才失败会更难追溯到 ERP 原始请求。
        for (Map.Entry<String, List<OutTaskParam>> entry : linesByValidateKey.entrySet()) {
            List<OutTaskParam> lines = entry.getValue();
            OutTaskParam head = lines.get(0);
            String oid = head.getOrderId();
            String batchSeq = isPendingOutOrderStation(head.getStationId()) ? head.getEntryWmsCode() : head.getBatchSeq();
            String batchSeq = resolveOutOrderTaskBatchKey(head);
            boolean hasZero = false;
            boolean hasPositive = false;
            List<Integer> orderedSeqs = new ArrayList<>(lines.size());
@@ -651,8 +661,11 @@
//            }
//        }
        // 延迟建单模式也必须确认托盘有库存且当前库位为满库位。
        // 这里只做准入校验,不在 controller 层提前锁库位;真正锁库位仍由后续 outOrderBatch 生成任务时处理。
        // 订单化后仍必须确认托盘有库存且当前库位为满库位。
        //
        // 这里只做准入校验,不在 controller 层提前锁库位:
        // - 如果这里提前改 loc_sts,会造成“订单未执行但库存已被占用”;
        // - 真正锁库位必须和 WrkMast/WrkDetl 创建在同一个事务里完成,所以仍由 outOrderBatch 处理。
        List<OutTaskParam> missingStock = Lists.newArrayList();
        List<OutTaskParam> missingLoc = Lists.newArrayList();
        for (OutTaskParam outTaskParam : groupedParams) {
@@ -682,25 +695,11 @@
            }
            return R.error("没有找到托盘码对应库位:" + String.join(",", badPalletIds)).add(missingLoc);
        }
        // 混合请求下先创建高站点出库订单,再创建低站点实时任务。
        // 这样同一个 orderId 同时包含高、低站点时,高站点建单不会被低站点刚生成的任务误判为“已有任务”。
        R orderResult = R.ok();
        if (!pendingOrderParams.isEmpty()) {
            orderResult = openService.outOrderCreatePakoutOrder(pendingOrderParams);
        }
        R directResult = R.ok();
        if (!linesByBatchSeq.isEmpty()) {
            directResult = openService.outOrderBatch(linesByBatchSeq, count - pendingOrderParams.size());
        }
        if (!linesByBatchSeq.isEmpty() && !pendingOrderParams.isEmpty()) {
            Map<String, Object> result = new LinkedHashMap<>();
            result.put("directTaskCount", count - pendingOrderParams.size());
            result.put("pendingOrderCount", pendingOrderParams.size());
            result.put("directResult", directResult);
            result.put("orderResult", orderResult);
            return R.ok().add(result);
        }
        return pendingOrderParams.isEmpty() ? directResult : orderResult;
        // ERP /outOrder 默认创建可执行订单:
        // - status=1:定时器允许扫描并生成任务;
        // - 生成任务后服务层会立即把 wrk_sts=11 的任务置为 pdcType=Y;
        // - 因此 ERP 侧仍保持“通知后自动出库”的体验,只是任务生成从接口线程转移到了订单调度链路。
        return openService.outOrderCreatePakoutOrder(groupedParams, true);
    }
    /**
@@ -748,8 +747,42 @@
        return openService.pakoutOrderPause(param);
    }
    private String buildOutOrderBatchKey(OutTaskParam param) {
        return param.getOrderId() + "#" + param.getBatchSeq();
    /**
     * 公开执行接口。
     *
     * IoT/MQTT 等非 ERP 入口会先创建 status=0 的出库订单,只有调用该接口后才允许生成任务和下发。
     * 该接口只支持 execute=1;中止仍走 /order/pakout/pause/default/v1 的 execute=2。
     */
    @PostMapping("/order/pakout/execute/default/v1")
    public synchronized R pakoutOrderExecute(@RequestBody OpenOrderPakoutExecuteParam param, HttpServletRequest request) {
        if (request != null) {
            log.info("[pakoutOrderExecute] cache: {}", param == null ? "null" : JSON.toJSONString(param));
            request.setAttribute("cache", param);
        }
        if (Cools.isEmpty(param) || Cools.isEmpty(param.getOrderId())) {
            return R.error("orderNo is empty");
        }
        return openService.pakoutOrderExecute(param);
    }
    private String buildOutOrderValidateKey(OutTaskParam param) {
        return param.getOrderId() + "#" + resolveOutOrderTaskBatchKey(param);
    }
    /**
     * 计算 /outOrder 入口的最终任务批次键。
     *
     * 这个值必须和 OpenServiceImpl.resolvePakoutTaskBatchSeq 保持一致:
     * - 高站点:entryWmsCode;
     * - 低站点:orderId。
     *
     * 入口校验和后续生成任务使用同一口径,才能保证 seq 校验、批次守卫和 WCS 下发批次一致。
     */
    private String resolveOutOrderTaskBatchKey(OutTaskParam param) {
        if (param != null && isPendingOutOrderStation(param.getStationId()) && !Cools.isEmpty(param.getEntryWmsCode())) {
            return param.getEntryWmsCode();
        }
        return param == null ? null : param.getOrderId();
    }
    private boolean isPendingOutOrderStation(String stationId) {
src/main/java/com/zy/asrs/service/OpenService.java
@@ -94,24 +94,37 @@
    R outOrderBatch(Map<String, List<OutTaskParam>> linesByBatchSeq,int count);
    /**
     * stationId > 600 的出库通知单先生成出库订单,不直接生成任务。
     * ERP / IoT 出库通知先生成出库订单,不直接生成任务。
     *
     * 该方法只负责把 ERP 参数落到 man_order_pakout / man_order_detl_pakout。
     * 该方法只负责把接口参数落到 man_order_pakout / man_order_detl_pakout。
     * 库位锁定、WrkMast/WrkDetl 创建和 WCS 下发都留给后续定时生成任务流程处理。
     */
    R outOrderCreatePakoutOrder(List<OutTaskParam> params);
    /**
     * 扫描待生成任务的出库订单,按进仓编号批量生成任务。
     * 创建出库订单,并指定订单是否立即允许定时器生成任务。
     *
     * 调度器入口:会遍历启用中的出库订单,每个订单最多生成一个当前可放行的 entryWmsCode 批次。
     * executable=true:订单 status=1,适用于 ERP /outOrder,创建后自动生成并下发。
     * executable=false:订单 status=0,适用于 IoT/MQTT 预创建,必须调用执行接口后才生成任务。
     *
     * 该开关只控制“是否允许生成后续任务”,不代表订单完成状态:
     * - 订单完成进度仍由 settle 和明细 work_qty/qty 判断;
     * - 中止/恢复继续复用同一个 status 字段,避免新增数据库字段。
     */
    R outOrderCreatePakoutOrder(List<OutTaskParam> params, boolean executable);
    /**
     * 扫描待生成任务的出库订单,按任务批次键批量生成任务。
     *
     * 调度器入口:会遍历启用中的出库订单,每个订单最多生成一个当前可放行的批次。
     * 高站点批次键为 entryWmsCode;低站点批次键为 orderNo。
     */
    R generatePendingPakoutOrderTasks();
    /**
     * 生成指定出库订单当前允许的一个进仓编号批次任务。
     * 生成指定出库订单当前允许的一个批次任务。
     *
     * orderNo 对应 WrkMast.userNo;entryWmsCode 在实现中会强制作为 WrkMast.batchSeq。
     * orderNo 对应 WrkMast.userNo;任务批次键在实现中会强制作为 WrkMast.batchSeq。
     */
    R generatePendingPakoutOrderTasks(String orderNo);
src/main/java/com/zy/asrs/service/impl/ExternalTaskFacadeServiceImpl.java
@@ -4,7 +4,7 @@
import com.core.common.Cools;
import com.core.common.R;
import com.zy.asrs.entity.param.MesToCombParam;
import com.zy.asrs.entity.param.OpenOrderPakoutPauseParam;
import com.zy.asrs.entity.param.OpenOrderPakoutExecuteParam;
import com.zy.asrs.entity.param.OutTaskParam;
import com.zy.asrs.service.ExternalTaskFacadeService;
import com.zy.asrs.service.LocDetlService;
@@ -14,6 +14,7 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.Objects;
/**
@@ -65,7 +66,17 @@
    }
    /**
     * 复用现有出库建单逻辑;当 autoConfirm=true 时,继续调用原有确认接口自动放行。
     * 复用统一出库订单化逻辑。
     *
     * MQTT/IoT 入口不再绕过订单直接生成 WrkMast:
     * - autoConfirm=false:只创建 status=0 的订单,等待外部调用执行接口;
     * - autoConfirm=true:创建订单后立即走执行逻辑,生成当前批次任务并确认可下发。
     *
     * 这样做是为了让 HTTP /outOrder 和 MQTT/IoT 直调共享同一套:
     * - 订单明细保存字段;
     * - 批次键计算;
     * - 中止取消和 work_qty 回滚;
     * - pdcType 放行。
     */
    @Override
    public R createOutboundTask(OutTaskParam param, boolean autoConfirm) {
@@ -87,22 +98,41 @@
        }
        if (param.getSeq() == null) {
            // 设备直调通常是单托盘出库,没有 ERP 顺序号;0 表示无序,和 /outOrder 的校验语义一致。
            param.setSeq(0);
        }
        R result = openService.outOrder(param, 1,1);
        if (!Objects.equals(result.get("code"), 200) || !autoConfirm) {
            return result;
        if (Cools.isEmpty(param.getBatchSeq())) {
            // batchSeq 是接口原始字段,明细里会保存;实际生成任务时低站点仍按 orderId 作为批次键。
            param.setBatchSeq(param.getOrderId());
        }
        if (isHighStation(param.getStationId()) && Cools.isEmpty(param.getEntryWmsCode())) {
            // IoT 直调常见为单托盘任务,没有 ERP 进仓编号;用 orderId 作为批次键,
            // 这样既满足高站点订单明细校验,也能让执行后 WrkMast.batchSeq 保持可追溯。
            param.setEntryWmsCode(param.getOrderId());
        }
        // IoT pick 约定为“收到即执行”,因此这里直接复用原有确认放行接口。
        OpenOrderPakoutPauseParam executeParam = new OpenOrderPakoutPauseParam();
        // IoT/MQTT 默认只预创建订单,status=0 不会被定时器扫描。
        // 只有 autoConfirm=true 或外部后续调用执行接口时,才会把 status 恢复为 1 并生成任务。
        R orderResult = openService.outOrderCreatePakoutOrder(Collections.singletonList(param), false);
        if (!Objects.equals(orderResult.get("code"), 200) || !autoConfirm) {
            return orderResult;
        }
        // IoT pick 约定为“收到即执行”,因此建单后直接复用公开执行接口的服务逻辑。
        OpenOrderPakoutExecuteParam executeParam = new OpenOrderPakoutExecuteParam();
        executeParam.setOrderId(param.getOrderId());
        executeParam.setExecute(1);
        R confirmResult = openService.pakoutOrderPause(executeParam);
        if (result.get("wrkNo") != null) {
            confirmResult.add(Cools.add("wrkNo", result.get("wrkNo")));
        return openService.pakoutOrderExecute(executeParam);
    }
    private boolean isHighStation(String stationId) {
        if (Cools.isEmpty(stationId)) {
            return false;
        }
        return confirmResult;
        try {
            return Integer.valueOf(stationId) > 600;
        } catch (NumberFormatException ignored) {
            return false;
        }
    }
}
src/main/java/com/zy/asrs/service/impl/OpenServiceImpl.java
@@ -431,40 +431,17 @@
            return R.error("正在下发任务给WCS,无法中止");
        }
        if (Objects.equals(param.getExecute(), 1)) {
            // execute=1 的含义已经从“仅确认已生成任务”扩展为“启动/恢复订单”:
            // 1. 订单 status=0 时恢复为 1;
            // 2. 已有 wrk_sts=11 的任务设置 pdcType=Y;
            // 3. 未生成的订单明细立即尝试生成当前可放行批次。
            // 因此这里直接复用公开执行接口的统一实现,避免 pause 和 execute 两套逻辑分叉。
            return executePakoutOrder(param.getOrderId());
        }
        OrderPakout orderPakout = orderPakoutService.selectByNo(param.getOrderId());
        List<WrkMast> activeTasks = findActiveOutboundTasks(param.getOrderId());
        if (Objects.equals(param.getExecute(), 1)) {
            // execute=1 同时处理两类对象:
            // 1. 未生成任务的出库订单:恢复 order.status=1,让定时器可以继续扫描。
            // 2. 已生成但待下发的任务:设置 pdcType=Y,允许 WorkMastScheduler 下发给 WCS。
            Map<String, Object> result = new HashMap<>();
            result.put("orderNo", param.getOrderId());
            result.put("execute", param.getExecute());
            boolean orderStatusUpdated = false;
            if (orderPakout != null && !Objects.equals(orderPakout.getStatus(), 1)) {
                orderPakout.setStatus(1);
                orderPakout.setUpdateBy(9527L);
                orderPakout.setUpdateTime(new Date());
                if (!orderPakoutService.updateById(orderPakout)) {
                    throw new CoolException("启动出库订单失败:" + param.getOrderId());
                }
                orderStatusUpdated = true;
            }
            int confirmedCount = confirmPendingOutboundTasks(activeTasks);
            R generateResult = null;
            int generatedConfirmedCount = 0;
            if (orderPakout != null) {
                // 启动后立即尝试生成当前可放行的进仓编号批次,避免必须等待下一轮定时扫描。
                generateResult = generatePendingPakoutOrderTasks(param.getOrderId());
                // 刚生成的任务初始状态仍是 11,这里再次确认,保持“启动接口调用后即可下发”的语义。
                generatedConfirmedCount = confirmPendingOutboundTasks(findActiveOutboundTasks(param.getOrderId()));
            }
            result.put("orderStatusUpdated", orderStatusUpdated);
            result.put("confirmedCount", confirmedCount + generatedConfirmedCount);
            result.put("generatedConfirmedCount", generatedConfirmedCount);
            result.put("generateResult", generateResult);
            return R.ok("出库订单启动成功").add(result);
        }
        if (Objects.equals(param.getExecute(), 2)) {
            // execute=2 先关闭订单开关,阻止定时器继续为未生成任务的明细建 WrkMast。
            // 已经生成的任务按状态分流:11 尚未下发,走本地取消;12/13 已下发或执行中,需要通知 WCS 取消。
@@ -474,6 +451,8 @@
            result.put("taskCount", activeTasks.size());
            boolean orderStatusUpdated = false;
            if (orderPakout != null && !Objects.equals(orderPakout.getStatus(), 0)) {
                // status=0 是订单级中止开关。先关订单,再取消任务,
                // 可以阻止定时器在取消过程中又生成下一批未下发任务。
                orderPakout.setStatus(0);
                orderPakout.setUpdateBy(9527L);
                orderPakout.setUpdateTime(new Date());
@@ -496,6 +475,7 @@
                hashMap.put("taskNo", wrkMast.getWrkNo());
                if (!Cools.isEmpty(wrkMast) && Objects.equals(wrkMast.getWrkSts(), 11L)) {
                    // wrk_sts=11 还没发到 WCS,本地取消即可,不需要产生 WCS 暂停指令。
                    // cancelWrkMast 会把工作档转历史、释放库位,并回滚订单明细 work_qty。
                    workService.cancelWrkMast(wrkMast.getWrkNo()+"", 9955L);
                    cancelledLocalTaskCount++;
                    continue;
@@ -529,6 +509,11 @@
     * pdcType 是现有 WorkMastScheduler 的下发开关:
     * - wrk_sts=11 且 pdcType=Y 时,调度器可以继续下发给 WCS。
     * - 已经是 Y 的任务保持幂等,不重复更新。
     *
     * 为什么不在 outOrderBatch 里直接强制 Y:
     * - outOrderBatch 仍被其他入口复用;
     * - MQTT/IoT autoConfirm=false 的订单需要先建单但不下发;
     * - 所以放行必须由“订单 status=1 的定时生成”或“执行接口”显式完成。
     */
    private int confirmPendingOutboundTasks(List<WrkMast> activeTasks) {
        if (Cools.isEmpty(activeTasks)) {
@@ -565,8 +550,102 @@
    }
    @Override
    @Transactional
    public R pakoutOrderExecute(OpenOrderPakoutExecuteParam param) {
        return null;
        if (param == null || Cools.isEmpty(param.getOrderId())) {
            throw new CoolException("orderNo不能为空");
        }
        if (param.getExecute() == null) {
            throw new CoolException("execute不能为空");
        }
        if (!Objects.equals(param.getExecute(), 1)) {
            throw new CoolException("execute仅支持1");
        }
        return executePakoutOrder(param.getOrderId());
    }
    /**
     * 出库订单执行/恢复的统一入口。
     *
     * 该方法同时服务:
     * - 新增公开执行接口;
     * - pakoutOrderPause(execute=1) 的恢复逻辑;
     * - IoT/MQTT autoConfirm=true 的即时执行逻辑。
     *
     * 执行语义分三步:
     * 1. 把订单 status 恢复为 1,让定时器和当前调用都可以生成任务;
     * 2. 对已经存在且 wrk_sts=11 的任务设置 pdcType=Y;
     * 3. 立即尝试生成当前允许的一个批次,并再次确认新生成任务可下发。
     *
     * 幂等性:
     * - 订单已是 status=1 时不会重复更新;
     * - 已经 pdcType=Y 的任务不会重复确认;
     * - 已经 work_qty 覆盖的明细不会再次生成任务。
     */
    private R executePakoutOrder(String orderNo) {
        if (Cools.isEmpty(orderNo)) {
            throw new CoolException("orderNo不能为空");
        }
        OrderPakout orderPakout = orderPakoutService.selectByNo(orderNo);
        List<WrkMast> activeTasks = findActiveOutboundTasks(orderNo);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("orderNo", orderNo);
        result.put("execute", 1);
        boolean orderStatusUpdated = false;
        if (orderPakout != null && !Objects.equals(orderPakout.getStatus(), 1)) {
            orderPakout.setStatus(1);
            orderPakout.setUpdateBy(9527L);
            orderPakout.setUpdateTime(new Date());
            if (!orderPakoutService.updateById(orderPakout)) {
                throw new CoolException("启动出库订单失败:" + orderNo);
            }
            orderStatusUpdated = true;
        }
        int confirmedCount = confirmPendingOutboundTasks(activeTasks);
        R generateResult = null;
        int generatedConfirmedCount = 0;
        if (orderPakout != null) {
            // 主动调用执行接口时,不等下一轮定时器,直接生成当前可放行的一个批次。
            // 如果当前批次被 OutboundBatchSeqReleaseGuard 阻塞,generateResult 会返回阻塞原因,
            // 不会抛错,也不会提前生成后续批次。
            generateResult = generatePendingPakoutOrderTasks(orderNo);
            // 新生成的任务初始状态为 11,这里再次确认,让执行接口返回后任务即可被调度器下发。
            generatedConfirmedCount = confirmPendingOutboundTasks(findActiveOutboundTasks(orderNo));
        }
        List<Integer> wrkNos = collectActiveOutboundWrkNos(orderNo);
        result.put("orderStatusUpdated", orderStatusUpdated);
        result.put("confirmedCount", confirmedCount + generatedConfirmedCount);
        result.put("generatedConfirmedCount", generatedConfirmedCount);
        result.put("generateResult", generateResult);
        result.put("wrkNos", wrkNos);
        if (!wrkNos.isEmpty()) {
            // IoT 单托盘调用依赖顶层 wrkNo 回写任务号;多托盘时返回当前订单活动任务的第一条。
            result.put("wrkNo", wrkNos.get(0));
        }
        R response = R.ok("出库订单启动成功").add(result);
        if (!wrkNos.isEmpty()) {
            // add(Map) 在部分调用方会按 data 读取,这里再显式写顶层字段,兼容 IoT 的 resolveWrkNo。
            response.put("wrkNo", wrkNos.get(0));
            response.put("wrkNos", wrkNos);
        }
        return response;
    }
    private List<Integer> collectActiveOutboundWrkNos(String orderNo) {
        List<WrkMast> activeTasks = findActiveOutboundTasks(orderNo);
        if (Cools.isEmpty(activeTasks)) {
            return Collections.emptyList();
        }
        List<Integer> wrkNos = new ArrayList<>();
        for (WrkMast wrkMast : activeTasks) {
            if (wrkMast != null && wrkMast.getWrkNo() != null) {
                wrkNos.add(wrkMast.getWrkNo());
            }
        }
        return wrkNos;
    }
    private List<WrkMast> findActiveOutboundTasks(String orderNo) {
@@ -1750,12 +1829,21 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public R outOrderCreatePakoutOrder(List<OutTaskParam> params) {
        return outOrderCreatePakoutOrder(params, true);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public R outOrderCreatePakoutOrder(List<OutTaskParam> params, boolean executable) {
        if (Cools.isEmpty(params)) {
            return R.ok();
        }
        // 一个 /outOrder 请求可能包含多个 orderId。按订单号分组后分别创建订单头,
        // 保证 orderId 始终对应 man_order_pakout.order_no,明细挂在对应订单下。
        //
        // 这里不按批次分组落订单头:同一个 ERP 单号下可能同时包含多个 entryWmsCode,
        // 它们应该是同一张出库订单的多个批次,而不是多张订单。
        Map<String, List<OutTaskParam>> paramsByOrderNo = new LinkedHashMap<>();
        for (OutTaskParam param : params) {
            validatePendingOutOrderParam(param);
@@ -1771,19 +1859,21 @@
            String orderNo = entry.getKey();
            // 延迟建单允许同一个 orderNo 再次下发:
            // 已下发/已完成明细保留追溯;未下发明细删除后,使用本次接口参数重新插入。
            // 如果当前订单还有活动 WrkMast,则不允许覆盖,避免同一托盘同时存在两条执行链。
            assertPendingPakoutOrderCanReplace(orderNo);
            OrderPakout order = orderPakoutService.selectByNo(orderNo);
            if (order == null) {
                order = createPendingPakoutOrderHeader(orderNo, now);
                order = createPendingPakoutOrderHeader(orderNo, now, executable);
            } else {
                assertNoNonReplaceablePendingDetailConflict(order, entry.getValue());
                removedUndispatchedDetailCount += removeUndispatchedPendingDetails(order.getId());
                refreshPendingPakoutOrderForResubmit(order, now);
                refreshPendingPakoutOrderForResubmit(order, now, executable);
            }
            for (OutTaskParam param : entry.getValue()) {
                // 明细完整保存接口字段,后续定时器可以无损还原 OutTaskParam 再复用 outOrderBatch。
                // 订单化的核心是“参数先持久化、任务后生成”:即使服务重启,定时器仍能继续处理未生成明细。
                OrderDetlPakout detail = buildPendingPakoutOrderDetl(order, param, now);
                if (!orderDetlPakoutService.insert(detail)) {
                    throw new CoolException("生成出库订单明细失败:" + orderNo);
@@ -1799,6 +1889,7 @@
        result.put("detailCount", detailCount);
        result.put("removedUndispatchedDetailCount", removedUndispatchedDetailCount);
        result.put("orderNos", orderNos);
        result.put("executable", executable);
        return R.ok("出库订单生成成功").add(result);
    }
@@ -1809,6 +1900,10 @@
        // status=1 表示未被 pakoutOrderPause 中止;
        // settle in (1,2) 表示未完成全部任务生成;
        // pakin_pakout_status=2 限定出库单,避免误扫入库/其他方向单据。
        //
        // status=0 的订单不会被这里扫描,典型来源是:
        // - IoT/MQTT autoConfirm=false 预创建订单;
        // - pakoutOrderPause execute=2 中止后的订单。
        List<OrderPakout> orders = orderPakoutService.selectList(new EntityWrapper<OrderPakout>()
                .eq("status", 1)
                .in("settle", Arrays.asList(1L, 2L))
@@ -1825,7 +1920,9 @@
                    continue;
                }
                scannedOrderCount++;
                // 每个订单单独走生成逻辑。单订单方法内部只会生成当前允许的一个 entryWmsCode 批次,
                // 每个订单单独走生成逻辑。单订单方法内部只会生成当前允许的一个批次:
                // - 高站点:一个 entryWmsCode 批次;
                // - 低站点:一个 orderNo 批次。
                // 因此定时器重复执行也不会一次性把后续所有进仓编号全部释放。
                R r = generatePendingPakoutOrderTasks(order.getOrderNo());
                details.add(r);
@@ -1872,48 +1969,61 @@
            return R.ok("出库订单不存在").add(buildGeneratePendingResult(orderNo, null, 0, 0, "order not found"));
        }
        if (!Objects.equals(order.getStatus(), 1)) {
            // 订单 status=0 时只返回“被暂停/中止”,不抛异常。
            // 这样定时器扫描和人工执行接口都可以幂等调用,调用方根据 reason 判断即可。
            return R.ok("出库订单已中止").add(buildGeneratePendingResult(orderNo, null, 0, 0, "order status disabled"));
        }
        if (!Objects.equals(order.getSettle(), 1L) && !Objects.equals(order.getSettle(), 2L)) {
            return R.ok("出库订单已处理").add(buildGeneratePendingResult(orderNo, null, 0, 0, "order settle not pending"));
        }
        // 按原明细 id 顺序读取,LinkedHashMap 会保持首次出现的 entryWmsCode 顺序。
        // 这样“进仓编号 A 做完后再生成 B”的顺序和 ERP 原始明细顺序一致。
        // 按原明细 id 顺序读取,LinkedHashMap 会保持首次出现的批次键顺序。
        // 高站点批次键为 entryWmsCode,可继续实现“进仓编号 A 做完后再生成 B”;
        // 低站点批次键为 orderNo,同一订单只形成一个批次。
        List<OrderDetlPakout> details = orderDetlPakoutService.selectList(new EntityWrapper<OrderDetlPakout>()
                .eq("order_id", order.getId())
                .eq("status", 1)
                .orderBy("id", true));
        LinkedHashMap<String, List<OrderDetlPakout>> detailsByEntryWmsCode = new LinkedHashMap<>();
        LinkedHashMap<String, List<OrderDetlPakout>> detailsByTaskBatchSeq = new LinkedHashMap<>();
        for (OrderDetlPakout detail : details) {
            if (!isPendingPakoutDetail(detail)) {
                continue;
            }
            detailsByEntryWmsCode
                    .computeIfAbsent(detail.getEntryWmsCode(), key -> new ArrayList<>())
            String taskBatchSeq = resolvePakoutTaskBatchSeq(orderNo, detail);
            // LinkedHashMap 保持首次出现顺序,保证同一订单内的批次生成顺序和 ERP 明细顺序一致。
            // 这对高站点尤其关键:第二个 entryWmsCode 只有在第一个批次满足释放条件后才会被处理。
            detailsByTaskBatchSeq
                    .computeIfAbsent(taskBatchSeq, key -> new ArrayList<>())
                    .add(detail);
        }
        if (detailsByEntryWmsCode.isEmpty()) {
        if (detailsByTaskBatchSeq.isEmpty()) {
            return R.ok("无待生成出库任务").add(buildGeneratePendingResult(orderNo, null, 0, 0, "no pending detail"));
        }
        // 一次只取当前最靠前的进仓编号批次。后续进仓编号是否允许生成,
        // 由 OutboundBatchSeqReleaseGuard 根据已有 WrkMast 的 batchSeq、wrk_sts=25 数量阈值等判断。
        Map.Entry<String, List<OrderDetlPakout>> candidate = detailsByEntryWmsCode.entrySet().iterator().next();
        String entryWmsCode = candidate.getKey();
        String blockMsg = outboundBatchSeqReleaseGuard.validateReady(orderNo, entryWmsCode);
        // 一次只取当前最靠前的批次。高站点的后续进仓编号是否允许生成,
        // 由 OutboundBatchSeqReleaseGuard 根据已有 WrkMast.batchSeq、wrk_sts=25 数量阈值等判断。
        Map.Entry<String, List<OrderDetlPakout>> candidate = detailsByTaskBatchSeq.entrySet().iterator().next();
        String taskBatchSeq = candidate.getKey();
        // 批次守卫读取 WrkMast.userNo/orderNo + WrkMast.batchSeq:
        // - 如果前序批次未完成,阻塞;
        // - 如果当前批次 wrk_sts=25 数量达到配置阈值,阻塞;
        // - 否则允许生成当前批次的下一组任务。
        String blockMsg = outboundBatchSeqReleaseGuard.validateReady(orderNo, taskBatchSeq);
        if (!Cools.isEmpty(blockMsg)) {
            return R.ok("出库进仓编号暂不满足生成条件").add(buildGeneratePendingResult(orderNo, entryWmsCode, 0, candidate.getValue().size(), blockMsg));
            return R.ok("出库批次暂不满足生成条件").add(buildGeneratePendingResult(orderNo, taskBatchSeq, 0, candidate.getValue().size(), blockMsg));
        }
        List<OutTaskParam> outTaskParams = new ArrayList<>();
        for (OrderDetlPakout detail : candidate.getValue()) {
            outTaskParams.add(buildOutTaskParam(orderNo, entryWmsCode, detail));
            outTaskParams.add(buildOutTaskParam(orderNo, taskBatchSeq, detail));
        }
        Map<String, List<OutTaskParam>> linesByBatchSeq = new LinkedHashMap<>();
        // outOrderBatch 的分组 key 就是任务 batchSeq。这里强制使用 entryWmsCode,
        // 确保 WrkMast.userNo=orderId 且 WrkMast.batchSeq=entryWmsCode,现有批次守卫才能生效。
        linesByBatchSeq.put(entryWmsCode, outTaskParams);
        // outOrderBatch 的分组 key 就是任务 batchSeq。这里强制使用计算后的任务批次键,
        // 确保 WrkMast.userNo=orderId 且 WrkMast.batchSeq 与批次守卫口径一致。
        //
        // 不直接使用订单明细里的 batchSeq,因为低站点要求固定 orderId,
        // 高站点要求固定 entryWmsCode,ERP 原始 batchSeq 只作为追溯字段保存。
        linesByBatchSeq.put(taskBatchSeq, outTaskParams);
        R r = outOrderBatch(linesByBatchSeq, outTaskParams.size());
        if (!Objects.equals(r.get("code"), 200)) {
            throw new CoolException("出库订单生成任务失败:" + orderNo);
@@ -1923,6 +2033,9 @@
            double remaining = getPendingDetailQty(detail);
            // work_qty 表示“已生成任务数量”,不是完成数量。
            // 任务完成后由 WorkMastHandler 递增 qty,因此重复定时扫描不会为同一托盘重复建任务。
            //
            // 中止取消时 WorkService.cancelWrkMast 会把这部分 work_qty 回滚;
            // 回滚后再次执行订单,未下发/已取消的明细可以重新生成任务。
            detail.setWorkQty(safeDouble(detail.getWorkQty()) + remaining);
            detail.setUpdateBy(9527L);
            detail.setUpdateTime(new Date());
@@ -1940,7 +2053,12 @@
            }
        }
        return R.ok("出库订单生成任务成功").add(buildGeneratePendingResult(orderNo, entryWmsCode, outTaskParams.size(), candidate.getValue().size(), null));
        // status=1 的订单代表当前允许执行。无论来自 ERP 自动订单还是执行接口恢复后的订单,
        // 生成任务后都立即设置 pdcType=Y,保证 WorkMastScheduler 可以继续下发。
        int confirmedCount = confirmPendingOutboundTasks(findActiveOutboundTasks(orderNo));
        Map<String, Object> result = buildGeneratePendingResult(orderNo, taskBatchSeq, outTaskParams.size(), candidate.getValue().size(), null);
        result.put("confirmedCount", confirmedCount);
        return R.ok("出库订单生成任务成功").add(result);
    }
    private void validatePendingOutOrderParam(OutTaskParam param) {
@@ -1950,25 +2068,39 @@
        if (Cools.isEmpty(param.getPalletId())) {
            throw new CoolException("托盘号不能为空");
        }
        if (Cools.isEmpty(param.getEntryWmsCode())) {
        if (Cools.isEmpty(param.getStationId())) {
            throw new CoolException("托盘「" + param.getPalletId() + "」出库口编码不能为空");
        }
        try {
            Integer.valueOf(param.getStationId());
        } catch (NumberFormatException ex) {
            throw new CoolException("托盘「" + param.getPalletId() + "」出库口编码格式错误:" + param.getStationId());
        }
        if (isPendingOutOrderStation(param.getStationId()) && Cools.isEmpty(param.getEntryWmsCode())) {
            throw new CoolException("托盘「" + param.getPalletId() + "」进仓编号不能为空");
        }
        if (!isPendingOutOrderStation(param.getStationId())) {
            throw new CoolException("托盘「" + param.getPalletId() + "」出库口不属于延迟生成任务范围");
        if (Cools.isEmpty(param.getBatchSeq())) {
            // batchSeq 是接口原字段,保存在订单明细用于追溯;实际任务批次键由 resolvePakoutTaskBatchSeq 决定。
            param.setBatchSeq(param.getOrderId());
        }
    }
    private OrderPakout createPendingPakoutOrderHeader(String orderNo, Date now) {
    private OrderPakout createPendingPakoutOrderHeader(String orderNo, Date now, boolean executable) {
        DocType docType = docTypeService.selectOrAdd(OUT_ORDER_PENDING_DOC_TYPE, Boolean.FALSE);
        OrderPakout order = new OrderPakout();
        order.setUuid(String.valueOf(snowflakeIdWorker.nextId()));
        order.setOrderNo(orderNo);
        order.setOrderTime(DateUtils.convert(now));
        order.setDocType(docType.getDocId());
        // settle=1 表示待生成任务;生成过至少一个进仓编号批次后置为 2。
        // status=1 是启动开关,pakoutOrderPause(execute=2) 会置 0 阻止定时器继续生成。
        // settle 仍沿用现有订单进度语义:
        // - 1:订单已创建,还没有生成过任务;
        // - 2:至少生成过一个任务批次,后续是否还有待生成明细继续看 anfme - work_qty;
        // - 大于 2:其他业务流程已处理/完成,不允许本接口覆盖。
        // status 是启动开关:
        // - ERP /outOrder 创建 executable=true,status=1,定时器可自动生成;
        // - IoT/MQTT 创建 executable=false,status=0,必须调用执行接口后才生成。
        order.setSettle(1L);
        order.setStatus(1);
        order.setStatus(executable ? 1 : 0);
        order.setCreateBy(9527L);
        order.setCreateTime(now);
        order.setUpdateBy(9527L);
@@ -1983,12 +2115,18 @@
        return order;
    }
    private void refreshPendingPakoutOrderForResubmit(OrderPakout order, Date now) {
    private void refreshPendingPakoutOrderForResubmit(OrderPakout order, Date now, boolean executable) {
        if (order == null) {
            return;
        }
        boolean hasDispatchedDetail = hasDispatchedPendingDetail(order.getId());
        order.setStatus(1);
        // 重复下发时按本次入口重新决定是否立即可执行:
        // ERP 重发继续 status=1;IoT/MQTT 预创建继续 status=0,等待执行接口。
        //
        // settle 的恢复口径:
        // - 如果已有已下发/已完成明细,保持 2,说明该订单曾进入生成任务阶段;
        // - 如果只剩未下发明细,重置为 1,表示可以作为新订单批次重新生成。
        order.setStatus(executable ? 1 : 0);
        order.setSettle(hasDispatchedDetail ? 2L : 1L);
        order.setUpdateBy(9527L);
        order.setUpdateTime(now);
@@ -1999,6 +2137,8 @@
    }
    private boolean hasDispatchedPendingDetail(Long orderId) {
        // 只要存在 work_qty>0 或 qty>0 的明细,就认为该订单已经进入过执行链路。
        // 这类明细不能在重发时直接删除,否则会丢失任务追溯或完成数量。
        List<OrderDetlPakout> details = orderDetlPakoutService.selectList(new EntityWrapper<OrderDetlPakout>()
                .eq("order_id", orderId)
                .eq("status", 1));
@@ -2059,6 +2199,8 @@
            return;
        }
        for (OrderDetlPakout detail : details) {
            // 本次重发如果包含了已经下发/完成的同托盘明细,直接拒绝。
            // 未下发明细可以删除重建;已下发/完成明细必须保留,以免破坏取消回滚和完成回写。
            if (detail == null || !newPalletIds.contains(detail.getPalletId()) || isUndispatchedPendingDetail(detail)) {
                continue;
            }
@@ -2067,6 +2209,8 @@
    }
    private boolean isUndispatchedPendingDetail(OrderDetlPakout detail) {
        // “未下发”不是看订单状态,而是看明细进度:
        // work_qty=0 且 qty=0 才说明既没有生成任务,也没有完成量,可以安全删除重建。
        return detail != null
                && safeDouble(detail.getWorkQty()) <= 0.0D
                && safeDouble(detail.getQty()) <= 0.0D;
@@ -2112,9 +2256,12 @@
        }
        OrderDetlPakout detail = new OrderDetlPakout();
        detail.sync(locDetl);
        // order_id/order_no 使用 man_order_pakout 的主档信息,不能使用 ERP 原始字段直接写入 id。
        detail.setOrderId(order.getId());
        detail.setOrderNo(order.getOrderNo());
        detail.setAnfme(resolvePendingOrderAnfme(param, locDetl));
        // work_qty:已生成任务数量;qty:已完成出库数量。
        // 两者初始都为 0,生成任务只加 work_qty,任务完成回写才加 qty。
        detail.setWorkQty(0.0D);
        detail.setQty(0.0D);
        detail.setStatus(1);
@@ -2123,6 +2270,8 @@
        detail.setUpdateBy(9527L);
        detail.setUpdateTime(now);
        detail.setPakinPakoutStatus(2);
        // 以下字段完整保存 OutTaskParam 原值,保证后续定时器可以还原出 OutTaskParam。
        // 注意:detail.batchSeq 只是 ERP 原始批次字段;真正生成 WrkMast.batchSeq 时会重新计算。
        detail.setBatchSeq(param.getBatchSeq());
        detail.setSeq(param.getSeq());
        detail.setPalletId(param.getPalletId());
@@ -2136,6 +2285,7 @@
        detail.setCubeNumber(param.getCubeNumber());
        detail.setTeu(param.getTeu());
        // standby1/standby2 继续镜像进仓编号和出库门号,兼容现有完成回写的 selectItem 匹配逻辑。
        // 低站点允许 entryWmsCode 为空,也要显式落空值,保证订单明细和 WrkDetl 维度一致。
        detail.setStandby1(param.getEntryWmsCode());
        detail.setStandby2(param.getOutDoorNo());
        return detail;
@@ -2162,22 +2312,40 @@
     * 这里只看 anfme - work_qty,避免定时器因为任务未完成而重复生成同一托盘任务。
     */
    private boolean isPendingPakoutDetail(OrderDetlPakout detail) {
        // 只要还存在待生成数量,并且能计算出任务批次键,就认为该明细可进入生成流程。
        // 是否真的生成由后续 OutboundBatchSeqReleaseGuard 决定。
        return detail != null
                && isPendingOutOrderStation(detail.getStationId())
                && !Cools.isEmpty(detail.getEntryWmsCode())
                && !Cools.isEmpty(resolvePakoutTaskBatchSeq(detail.getOrderNo(), detail))
                && getPendingDetailQty(detail) > 0.0D;
    }
    /**
     * 订单明细转任务时的批次键。
     *
     * entryWmsCode 存在时优先作为进仓编号批次,兼容 stationId > 600 的分批放行;
     * entryWmsCode 为空时使用订单号,满足低站点 ERP 出库统一订单化后的批次边界。
     *
     * stationId <= 600 即使传了 entryWmsCode,也不参与批次键计算;
     * 这是为了满足“低站点批次边界按 orderId 处理”的接口约定。
     */
    private String resolvePakoutTaskBatchSeq(String orderNo, OrderDetlPakout detail) {
        if (detail != null && isPendingOutOrderStation(detail.getStationId()) && !Cools.isEmpty(detail.getEntryWmsCode())) {
            return detail.getEntryWmsCode();
        }
        return orderNo;
    }
    /**
     * 从订单明细还原 outOrderBatch 需要的参数。
     *
     * batchSeq 必须强制改为 entryWmsCode,而不是沿用 ERP 原始 batchSeq;
     * 这样后续 WCS 下发守卫才能以进仓编号为顺序边界。
     * batchSeq 必须强制改为任务批次键:
     * - 高站点:entryWmsCode,WCS 下发守卫以进仓编号为顺序边界;
     * - 低站点:orderNo,整张 ERP 订单作为一个批次。
     */
    private OutTaskParam buildOutTaskParam(String orderNo, String entryWmsCode, OrderDetlPakout detail) {
    private OutTaskParam buildOutTaskParam(String orderNo, String taskBatchSeq, OrderDetlPakout detail) {
        OutTaskParam param = new OutTaskParam();
        param.setOrderId(orderNo);
        param.setBatchSeq(entryWmsCode);
        param.setBatchSeq(taskBatchSeq);
        param.setSeq(detail.getSeq());
        param.setPalletId(detail.getPalletId());
        param.setStationId(detail.getStationId());
@@ -2194,10 +2362,12 @@
        return param;
    }
    private Map<String, Object> buildGeneratePendingResult(String orderNo, String entryWmsCode, int generatedTaskCount, int pendingDetailCount, String reason) {
    private Map<String, Object> buildGeneratePendingResult(String orderNo, String taskBatchSeq, int generatedTaskCount, int pendingDetailCount, String reason) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("orderNo", orderNo);
        result.put("entryWmsCode", entryWmsCode);
        result.put("batchSeq", taskBatchSeq);
        // 保留旧 key,避免外部日志或页面仍按 entryWmsCode 读取高站点批次。
        result.put("entryWmsCode", taskBatchSeq);
        result.put("generatedTaskCount", generatedTaskCount);
        result.put("pendingDetailCount", pendingDetailCount);
        if (!Cools.isEmpty(reason)) {