自动化立体仓库 - WMS系统
src/main/java/com/zy/asrs/service/impl/OpenServiceImpl.java
@@ -14,11 +14,14 @@
import com.zy.asrs.mapper.TagMapper;
import com.zy.asrs.service.*;
import com.zy.asrs.task.core.ReturnT;
import com.zy.asrs.task.support.OutboundBatchSeqReleaseGuard;
import com.zy.asrs.task.support.WorkPublishLockKeys;
import com.zy.asrs.utils.MatUtils;
import com.zy.asrs.utils.OrderInAndOutUtil;
import com.zy.asrs.utils.Utils;
import com.zy.common.constant.AgvConstant;
import com.zy.common.constant.ArmConstant;
import com.zy.common.entity.Parameter;
import com.zy.common.model.DetlDto;
import com.zy.common.model.LocDetlDto;
import com.zy.common.model.LocDto;
@@ -27,12 +30,15 @@
import com.zy.common.service.CommonService;
import com.zy.common.utils.HttpHandler;
import com.zy.common.utils.NodeUtils;
import com.zy.common.utils.RedisUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.stream.Collectors;
@@ -43,10 +49,29 @@
@Service
public class OpenServiceImpl implements OpenService {
    private static final Map<Integer, BigDecimal> INBOUND_WEIGHT_FACTOR_BY_SOURCE_STA;
    private static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
    private static final int DEFAULT_OUT_ORDER_BATCH_PRIORITY = 100;
    private static final int DEFAULT_OUT_ORDER_BATCH_PRIORITY_THRESHOLD = 1;
    // ERP 出库口大于该阈值时,/outOrder 只落出库订单,由定时器后续生成任务。
    private static final int PENDING_OUT_ORDER_STATION_THRESHOLD = 600;
    // 延迟出库订单使用独立单据类型,便于和人工/页面创建的出库单区分来源。
    private static final String OUT_ORDER_PENDING_DOC_TYPE = "接口出库单";
    static {
        Map<Integer, BigDecimal> factorMap = new HashMap<>();
        factorMap.put(112, new BigDecimal("0.98"));
        INBOUND_WEIGHT_FACTOR_BY_SOURCE_STA = Collections.unmodifiableMap(factorMap);
    }
    @Autowired
    private OrderService orderService;
    @Autowired
    private OrderDetlService orderDetlService;
    @Autowired
    private OrderPakoutService orderPakoutService;
    @Autowired
    private OrderDetlPakoutService orderDetlPakoutService;
    @Autowired
    private SnowflakeIdWorker snowflakeIdWorker;
    @Autowired
@@ -87,18 +112,36 @@
    private String mesUrl;
    @Value("${mes.stationaddress}")
    private String stationAddress;
    @Value("${erp.address.URL:}")
    private String erpUrl;
    @Value("${erp.switch.ErpReportOld}")
    private boolean erpReportOld;
    @Value("${erp.address.Inaddress:}")
    private String erpInAddress;
    @Value("${erp.address.OutErroraddress:}")
    private String erpOutErrorAddress;
    @Autowired
    private WaitPakinService waitPakinService;
    @Autowired
    private WaitPakinLogService waitPakinLogService;
    @Autowired
    private WrkMastService wrkMastService;
    @Autowired
    private WrkMastLogService wrkMastLogService;
    @Autowired
    private WrkDetlLogService wrkDetlLogService;
    @Autowired
    private WcsApiService wcsApiService;
    @Autowired
    private WorkService workService;
    @Autowired
    private RedisUtil redisUtil;
    @Autowired
    private BasCrnpService basCrnpService;
    @Autowired
    private ApiLogService apiLogService;
    @Autowired
    private OutboundBatchSeqReleaseGuard outboundBatchSeqReleaseGuard;
    @Override
    @Transactional
@@ -381,73 +424,116 @@
        if (param.getExecute() == null) {
            throw new CoolException("execute不能为空");
        }
        List<WrkMast> activeTasks = findActiveOutboundTasks(param.getOrderId());
        if (Objects.equals(param.getExecute(), 1)) {
            // ERP确认立即执行,仅处理待下发的出库任务。
            List<WrkMast> pendingTasks = activeTasks.stream()
                    .filter(wrkMast -> wrkMast != null && Objects.equals(wrkMast.getWrkSts(), 11L))
                    .collect(Collectors.toList());
            Map<String, Object> result = new HashMap<>();
            result.put("orderNo", param.getOrderId());
            result.put("taskCount", pendingTasks.size());
            if (pendingTasks.isEmpty()) {
                result.put("confirmedCount", 0);
                return R.ok("无有效出库任务").add(result);
            }
            Date now = new Date();
            int confirmedCount = 0;
            for (WrkMast wrkMast : pendingTasks) {
                if (wrkMast == null || "Y".equalsIgnoreCase(wrkMast.getPdcType())) {
                    continue;
                }
                wrkMast.setPdcType("Y");
//            wrkMast.setUpdMk("ERP_CONFIRMED");
//            wrkMast.setManuType("ERP_CONFIRM_OUT");
                wrkMast.setModiTime(now);
                wrkMast.setModiUser(9527L);
                if (!wrkMastService.updateById(wrkMast)) {
                    throw new CoolException("确认执行出库任务失败: " + wrkMast.getWrkNo());
                }
                confirmedCount++;
            }
            result.put("confirmedCount", confirmedCount);
            return R.ok(confirmedCount == 0 ? "任务已确认执行" : "ERP确认执行出库成功").add(result);
        // 中止时先避开正在下发给 WCS 的窗口,防止本地把订单置停后,
        // WCS 侧仍收到同一批任务,造成订单状态和设备执行状态分叉。
        if (Objects.equals(param.getExecute(), 2)
                && redisUtil.hasKey(WorkPublishLockKeys.outboundUserNoLock(param.getOrderId()))) {
            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(), 2)) {
            // ERP请求取消任务:按 plt_type 从大到小先 WCS 再 WMS;失败或异常则停止后续,接口仍返回原成功结构。
            // execute=2 先关闭订单开关,阻止定时器继续为未生成任务的明细建 WrkMast。
            // 已经生成的任务按状态分流:11 尚未下发,走本地取消;12/13 已下发或执行中,需要通知 WCS 取消。
            Map<String, Object> result = new HashMap<>();
            result.put("orderNo", param.getOrderId());
            result.put("execute", param.getExecute());
            result.put("taskCount", activeTasks.size());
            if (activeTasks.isEmpty()) {
                return R.ok("无有效出库任务").add(result);
            boolean orderStatusUpdated = false;
            if (orderPakout != null && !Objects.equals(orderPakout.getStatus(), 0)) {
                // status=0 是订单级中止开关。先关订单,再取消任务,
                // 可以阻止定时器在取消过程中又生成下一批未下发任务。
                orderPakout.setStatus(0);
                orderPakout.setUpdateBy(9527L);
                orderPakout.setUpdateTime(new Date());
                if (!orderPakoutService.updateById(orderPakout)) {
                    throw new CoolException("中止出库订单失败:" + param.getOrderId());
                }
                orderStatusUpdated = true;
            }
            List<WrkMast> sorted = new ArrayList<>(activeTasks);
            sorted.sort(Comparator.comparing(WrkMast::getPltType, Comparator.nullsLast(Comparator.reverseOrder())));
            for (WrkMast wrkMast : sorted) {
                try {
                    if (!Cools.isEmpty(wrkMast) && wrkMast.getWrkSts() == 11L) {
                        workService.cancelWrkMast(wrkMast.getWrkNo() + "", 9955L);
                        continue;
                    }
                    HashMap<String, Object> hashMap = new HashMap<>();
                    hashMap.put("taskNo", wrkMast.getWrkNo());
                    List<HashMap<String, Object>> one = new ArrayList<>();
                    one.add(hashMap);
                    R wcsR = wcsApiService.pauseOutTasks(one);
                    requireWcsPauseOk(wcsR);
                    workService.cancelWrkMast(wrkMast.getWrkNo() + "", 9955L);
                } catch (Exception e) {
                    log.warn("[pakoutOrderPause] execute=2 取消中止, orderNo={}, err={}", param.getOrderId(), e.getMessage());
                    break;
            result.put("orderStatusUpdated", orderStatusUpdated);
            if (activeTasks.isEmpty()) {
                result.put("cancelledLocalTaskCount", 0);
                result.put("pausedWcsTaskCount", 0);
                return R.ok("出库订单已中止").add(result);
            }
            List<HashMap<String,Object>> taskList = new ArrayList<>();
            List<WrkMast> wcsCancelTasks = new ArrayList<>();
            int cancelledLocalTaskCount = 0;
            for (WrkMast wrkMast : activeTasks) {
                HashMap<String,Object> hashMap = new HashMap<>();
                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;
                }
                taskList.add(hashMap);
                wcsCancelTasks.add(wrkMast);
            }
            int cancelledWcsTaskCount = 0;
            if (!taskList.isEmpty()) {
                // wrk_sts=12/13 等已进入 WCS 侧的任务必须校验 WCS 返回,失败则事务回滚,避免本地误报已取消。
                R wcsR = wcsApiService.pauseOutTasks(taskList);
                requireWcsPauseOk(wcsR);
                // WCS 已确认取消后,本地也要取消任务并回滚订单明细 work_qty。
                // 回滚逻辑在 WorkService.cancelWrkMast 中统一处理,确保 WCS 回调 task_cancel 走同一套口径。
                for (WrkMast wrkMast : wcsCancelTasks) {
                    workService.cancelWrkMast(wrkMast.getWrkNo()+"", 9955L);
                    cancelledWcsTaskCount++;
                }
            }
            return R.ok("取消任务已发送至WCS").add(result);
            result.put("cancelledLocalTaskCount", cancelledLocalTaskCount);
            result.put("pausedWcsTaskCount", taskList.size());
            result.put("cancelledWcsTaskCount", cancelledWcsTaskCount);
            return R.ok("出库订单已中止").add(result);
        }
        throw new CoolException("reason仅支持1或2");
    }
    /**
     * 将已生成但还未允许下发的出库任务置为可下发。
     *
     * 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)) {
            return 0;
        }
        Date now = new Date();
        int confirmedCount = 0;
        for (WrkMast wrkMast : activeTasks) {
            if (wrkMast == null || !Objects.equals(wrkMast.getWrkSts(), 11L) || "Y".equalsIgnoreCase(wrkMast.getPdcType())) {
                continue;
            }
            wrkMast.setPdcType("Y");
            wrkMast.setModiTime(now);
            wrkMast.setModiUser(9527L);
            if (!wrkMastService.updateById(wrkMast)) {
                throw new CoolException("确认执行出库任务失败: " + wrkMast.getWrkNo());
            }
            confirmedCount++;
        }
        return confirmedCount;
    }
    /** WCS 返回非成功码时抛错 */
@@ -464,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) {
@@ -664,6 +844,117 @@
    @Transactional
    public List<StockVo> queryStock() {
        return locDetlService.queryStockTotal();
    }
    @Override
    public R reportPakinHistoryToErp(List<String> barcodes) {
        List<String> normalizedBarcodes = normalizeBarcodes(barcodes);
        if (normalizedBarcodes.isEmpty()) {
            return R.error("托盘码集合不能为空");
        }
        if (!isErpReportEnabled()) {
            return R.error("ERP reporting is disabled");
        }
        if (Cools.isEmpty(erpInAddress)) {
            return R.error("ERP入库上报地址未配置");
        }
        Map<String, WrkMastLog> latestInboundLogMap = loadLatestInboundHistoryLogMap(normalizedBarcodes);
        Map<Integer, List<WrkDetlLog>> wrkDetlLogMap = loadWrkDetlLogMap(latestInboundLogMap.values());
        Map<String, WaitPakinLog> waitPakinLogMap = loadWaitPakinLogMap(normalizedBarcodes);
        String requestUrl = buildErpInboundRequestUrl();
        List<Map<String, Object>> rows = new ArrayList<>();
        int successCount = 0;
        int failCount = 0;
        for (String barcode : normalizedBarcodes) {
            Map<String, Object> row = new LinkedHashMap<>();
            row.put("barcode", barcode);
            WrkMastLog wrkMastLog = latestInboundLogMap.get(barcode);
            if (wrkMastLog == null) {
                row.put("success", false);
                row.put("message", "未找到最新入库历史记录");
                rows.add(row);
                failCount++;
                continue;
            }
            WaitPakinLog waitPakinLog = waitPakinLogMap.get(barcode);
            List<WrkDetlLog> wrkDetlLogs = wrkDetlLogMap.getOrDefault(wrkMastLog.getWrkNo(), Collections.emptyList());
            ErpPakinReportParam param = buildInboundErpParam(barcode, wrkMastLog, wrkDetlLogs, waitPakinLog);
            if (Cools.isEmpty(param.getPalletId())) {
                row.put("success", false);
                row.put("message", "托盘码缺少上报字段[palletId]");
                rows.add(row);
                failCount++;
                continue;
            }
            if (Cools.isEmpty(param.getLocId())) {
                row.put("success", false);
                row.put("message", "托盘码缺少上报字段[locId]");
                rows.add(row);
                failCount++;
                continue;
            }
            String request = JSON.toJSONString(param);
            String response = "";
            boolean success = false;
            String errorMsg = null;
            try {
                response = new HttpHandler.Builder()
                        .setUri(erpUrl)
                        .setPath(erpInAddress)
                        .setJson(request)
                        .build()
                        .doPost();
                success = isErpCallSuccess(response);
                if (!success) {
                    errorMsg = extractErpCallError(response);
                }
            } catch (Exception e) {
                errorMsg = e.getMessage();
            } finally {
                try {
                    apiLogService.save(
                            "Inbound ERP Report",
                            requestUrl,
                            null,
                            "127.0.0.1",
                            request,
                            response,
                            success,
                            "barcode=" + barcode + ",workNo=" + wrkMastLog.getWrkNo()
                    );
                } catch (Exception logEx) {
                    log.error("save inbound erp api log failed", logEx);
                }
            }
            row.put("workNo", wrkMastLog.getWrkNo());
            Integer sourceStaNo = resolveInboundSourceStaNo(wrkMastLog);
            row.put("sourceStaNo", sourceStaNo);
            row.put("weightFactor", resolveInboundWeightFactor(sourceStaNo));
            row.put("erpWeight", param.getWeight());
            row.put("success", success);
            row.put("message", success ? "OK" : errorMsg);
            rows.add(row);
            if (success) {
                successCount++;
            } else {
                failCount++;
            }
        }
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("total", normalizedBarcodes.size());
        result.put("successCount", successCount);
        result.put("failCount", failCount);
        result.put("rows", rows);
        return R.ok().add(result);
    }
    @Override
@@ -1307,6 +1598,9 @@
        return mesUrl + stationAddress;
    }
    /**
     * 7.3 组托信息下发。
     */
    @Override
    public R mesToComb(MesToCombParam param) {
        if (Cools.isEmpty(param.getPalletId())) {
@@ -1339,15 +1633,27 @@
        waitPakin.setIoStatus("N");     // 入出状态
        waitPakin.setAnfme(param.getAnfme());  // 数量
        waitPakin.setFreqType(param.getFreqType());
        waitPakin.setContainerNo(param.getContainerNo());
        waitPakin.setTeu(param.getTeu());
        waitPakin.setPlateNo(param.getPlateNo());
        waitPakin.setTrainNo(param.getTrainNo());
        waitPakin.setCubeNumber(param.getCubeNumber());
        waitPakin.setStatus("Y");    // 状态
        waitPakin.setAppeUser(9995L);
        waitPakin.setAppeTime(now);
        waitPakin.setModiUser(9995L);
        waitPakin.setModiTime(now);
        waitPakin.setOrderNo(String.valueOf(param.getOrderId()));
        waitPakin.setOrderNo(String.valueOf(param.getBizNo()));
        waitPakin.setOrigin(String.valueOf(param.getStorageArea()));//建议入库区域
        waitPakin.setManu(String.valueOf(param.getLocId()));//mes具体库位编号
        waitPakin.setThreeCode(param.getBizNo());
        // 7.3 新增字段复用现有组托档承载位,不新增列。
        waitPakin.setSuppCode(param.getCustomerId());
        waitPakin.setSupp(param.getCustomerName());
        waitPakin.setStandby3(param.getItem());
        waitPakin.setStandby1(param.getEntryWmsCode());
        waitPakin.setStandby2(param.getOutDoorNo());
        waitPakin.setBeBatch(param.getPackage1());//是否散货,0 非散货;1 散货;为了管控出货速率,散货可以出慢点。
        // ERP 入口默认打 erp,MQTT 组托会在参数里显式传 aws。
        waitPakin.setBoxType1(Cools.isEmpty(param.getBoxType1()) ? "erp" : param.getBoxType1());
@@ -1357,15 +1663,40 @@
        return R.ok().add(Cools.add("palletId", param.getPalletId()).add("orderId", param.getOrderId()));
    }
    /**
     * 7.11 出库通知单(传递有序无序规则)单条建单。
     */
    @Override
    public R outOrder(OutTaskParam param,int count) {
    public R outOrder(OutTaskParam param,int count,int i) {
        return outOrder(param, count, 0 ,i);
    }
    private R outOrder(OutTaskParam param, int count, int teu , int i) {
        LocMast locMast = locMastService.selectOne(new EntityWrapper<LocMast>().eq("loc_sts", "F").eq("barcode", param.getPalletId()));
        if (locMast == null) {
            throw new CoolException("没有找到托盘码=" + param.getPalletId() + "对应的库位");
        }
        if (Cools.isEmpty(param.getStationId())) {
            throw new CoolException("出库口编码不能为空");
        }
        if (Cools.isEmpty(param.getBatchSeq())) {
            throw new CoolException("批次标识不能为空");
        }
        if (param.getSeq() == null || param.getSeq() < 0) {
            throw new CoolException("出库顺序不能为空且不能小于0");
        }
        Integer ioType = 101;
        // 获取路径
        StaDesc staDesc = staDescService.queryCrnStn(ioType, locMast.getCrnNo(), Integer.valueOf(param.getStationId()));
        Integer stationId;
        try {
            stationId = Integer.valueOf(param.getStationId());
        } catch (NumberFormatException ex) {
            throw new CoolException("出库口编码格式错误:" + param.getStationId());
        }
        StaDesc staDesc = staDescService.queryCrnStn(ioType, locMast.getCrnNo(), stationId);
        if (staDesc == null) {
            throw new CoolException("未找到出库口=" + param.getStationId() + "对应路径");
        }
        Date now = new Date();
        // 生成工作号
        int workNo = commonService.getWorkNo(WorkNoType.getWorkNoType(ioType));
@@ -1375,7 +1706,7 @@
        wrkMast.setIoTime(now);
        wrkMast.setWrkSts(11L); // 工作状态:11.生成出库ID
        wrkMast.setIoType(ioType); // 入出库状态
        wrkMast.setIoPri(13D); // 优先级:13
        wrkMast.setIoPri(Double.valueOf(i)); // 优先级
        wrkMast.setCrnNo(locMast.getCrnNo());
        wrkMast.setSourceStaNo(staDesc.getCrnStn()); // 源站
        wrkMast.setStaNo(staDesc.getStnNo()); // 目标站
@@ -1386,8 +1717,16 @@
        wrkMast.setExitMk("N"); // 退出
        wrkMast.setEmptyMk("N"); // 空板
        wrkMast.setLinkMis("N");
        wrkMast.setPdcType("N");
        wrkMast.setContainerNo(param.getContainerNo());
        wrkMast.setTeu(teu);
        wrkMast.setPdcType(locMast.getCrnNo()>=19?"Y":"N");//自动任务下发标记
        wrkMast.setPlateNo(param.getPlateNo());
        wrkMast.setTrainNo(param.getTrainNo());
        wrkMast.setFreqType(param.getFreqType());
        wrkMast.setCubeNumber(param.getCubeNumber());
        // 7.11:orderId 存 userNo,batchSeq 存批次标识,seq 存批次内顺序。
        wrkMast.setUserNo(param.getOrderId());//订单号
        wrkMast.setBatchSeq(param.getBatchSeq());//订单内批次标识
        wrkMast.setPltType(param.getSeq());//出库顺序,从1开始
        wrkMast.setTakeNone("0");  //0非自动
        wrkMast.setAppeUser(9995L); // 操作人员数据
@@ -1414,7 +1753,17 @@
            wrkDetl.setAppeUser(9995L);
            wrkDetl.setModiTime(now);
            wrkDetl.setModiUser(9995L);
            wrkDetl.setSupp(param.getSeq()+"/"+count);
            wrkDetl.setTeu(teu);
            wrkDetl.setContainerNo(param.getContainerNo());
            wrkDetl.setPlateNo(param.getPlateNo());
            wrkDetl.setTrainNo(param.getTrainNo());
            wrkDetl.setFreqType(param.getFreqType());
            wrkDetl.setCubeNumber(param.getCubeNumber());
            // 7.11:entryWmsCode、outDoorNo 复用明细备用字段。
            wrkDetl.setStandby1(param.getEntryWmsCode());
            wrkDetl.setStandby2(param.getOutDoorNo());
            wrkDetl.setSupp(count+"");
            wrkDetl.setTeu(param.getTeu());
            if (!wrkDetlService.insert(wrkDetl)) {
                throw new CoolException("保存工作档明细失败");
@@ -1436,18 +1785,1118 @@
        return R.ok().add(Cools.add("wrkNo", workNo).add("orderId", param.getOrderId()));
    }
    /**
     * 7.11 出库通知单(传递有序无序规则)批量建单。
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public R outOrderBatch(List<OutTaskParam> params) {
        int n = params.size();
        for (OutTaskParam outTaskParam : params) {
            R r = outOrder(outTaskParam, n);
            if (!Objects.equals(r.get("code"), 200)) {
                throw new CoolException("出库建单失败");
    public R outOrderBatch(Map<String, List<OutTaskParam>> linesByBatchSeq,int count) {
        for (Map.Entry<String, List<OutTaskParam>> entry : linesByBatchSeq.entrySet()) {
            int i = DEFAULT_OUT_ORDER_BATCH_PRIORITY;
            int j = 0;
            int priorityThreshold = getOutOrderBatchPriorityThreshold();
            for (OutTaskParam outTaskParam : entry.getValue()) {
                int teu = Cools.isEmpty(outTaskParam.getTeu())?0:outTaskParam.getTeu();
                R r = outOrder(outTaskParam, count, teu ,i);
                if (!Objects.equals(r.get("code"), 200)) {
                    throw new CoolException("出库建单失败");
                }
                j++;
                if (j >= priorityThreshold) {
                    i--;
                    j = 0;
                }
            }
        }
        return R.ok();
    }
    private int getOutOrderBatchPriorityThreshold() {
        Parameter parameter = Parameter.get();
        if (parameter == null || Cools.isEmpty(parameter.getOutOrderBatchPriorityThreshold())) {
            return DEFAULT_OUT_ORDER_BATCH_PRIORITY_THRESHOLD;
        }
        try {
            int threshold = Integer.parseInt(parameter.getOutOrderBatchPriorityThreshold().trim());
            return threshold <= 0 ? DEFAULT_OUT_ORDER_BATCH_PRIORITY_THRESHOLD : threshold;
        } catch (NumberFormatException ignored) {
            return DEFAULT_OUT_ORDER_BATCH_PRIORITY_THRESHOLD;
        }
    }
    @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);
            paramsByOrderNo.computeIfAbsent(param.getOrderId(), key -> new ArrayList<>()).add(param);
        }
        Date now = new Date();
        int orderCount = 0;
        int detailCount = 0;
        int removedUndispatchedDetailCount = 0;
        List<String> orderNos = new ArrayList<>();
        for (Map.Entry<String, List<OutTaskParam>> entry : paramsByOrderNo.entrySet()) {
            String orderNo = entry.getKey();
            // 延迟建单允许同一个 orderNo 再次下发:
            // 已下发/已完成明细保留追溯;未下发明细删除后,使用本次接口参数重新插入。
            // 如果当前订单还有活动 WrkMast,则不允许覆盖,避免同一托盘同时存在两条执行链。
            assertPendingPakoutOrderCanReplace(orderNo);
            OrderPakout order = orderPakoutService.selectByNo(orderNo);
            if (order == null) {
                order = createPendingPakoutOrderHeader(orderNo, now, executable);
            } else {
                assertNoNonReplaceablePendingDetailConflict(order, entry.getValue());
                removedUndispatchedDetailCount += removeUndispatchedPendingDetails(order.getId());
                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);
                }
                detailCount++;
            }
            orderCount++;
            orderNos.add(orderNo);
        }
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("orderCount", orderCount);
        result.put("detailCount", detailCount);
        result.put("removedUndispatchedDetailCount", removedUndispatchedDetailCount);
        result.put("orderNos", orderNos);
        result.put("executable", executable);
        return R.ok("出库订单生成成功").add(result);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public R generatePendingPakoutOrderTasks() {
        // 定时入口只扫描启用中的出库订单:
        // 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))
                .eq("pakin_pakout_status", 2)
                .orderBy("create_time", true));
        Map<String, Object> result = new LinkedHashMap<>();
        int scannedOrderCount = 0;
        int generatedOrderCount = 0;
        int generatedTaskCount = 0;
        List<Object> details = new ArrayList<>();
        if (!Cools.isEmpty(orders)) {
            for (OrderPakout order : orders) {
                if (order == null || Cools.isEmpty(order.getOrderNo())) {
                    continue;
                }
                scannedOrderCount++;
                // 每个订单单独走生成逻辑。单订单方法内部只会生成当前允许的一个批次:
                // - 高站点:一个 entryWmsCode 批次;
                // - 低站点:一个 orderNo 批次。
                // 因此定时器重复执行也不会一次性把后续所有进仓编号全部释放。
                R r = generatePendingPakoutOrderTasks(order.getOrderNo());
                details.add(r);
                int taskCount = extractGeneratedTaskCount(r);
                if (taskCount > 0) {
                    generatedOrderCount++;
                    generatedTaskCount += taskCount;
                }
            }
        }
        result.put("scannedOrderCount", scannedOrderCount);
        result.put("generatedOrderCount", generatedOrderCount);
        result.put("generatedTaskCount", generatedTaskCount);
        result.put("details", details);
        return R.ok().add(result);
    }
    private int extractGeneratedTaskCount(R r) {
        if (r == null) {
            return 0;
        }
        Object generatedObj = r.get("generatedTaskCount");
        if (generatedObj instanceof Number) {
            return ((Number) generatedObj).intValue();
        }
        Object dataObj = r.get("data");
        if (dataObj instanceof Map) {
            Object dataGeneratedObj = ((Map) dataObj).get("generatedTaskCount");
            if (dataGeneratedObj instanceof Number) {
                return ((Number) dataGeneratedObj).intValue();
            }
        }
        return 0;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public R generatePendingPakoutOrderTasks(String orderNo) {
        if (Cools.isEmpty(orderNo)) {
            throw new CoolException("orderNo不能为空");
        }
        OrderPakout order = orderPakoutService.selectByNo(orderNo);
        if (order == null) {
            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”;
        // 低站点批次键为 orderNo,同一订单只形成一个批次。
        List<OrderDetlPakout> details = orderDetlPakoutService.selectList(new EntityWrapper<OrderDetlPakout>()
                .eq("order_id", order.getId())
                .eq("status", 1)
                .orderBy("id", true));
        LinkedHashMap<String, List<OrderDetlPakout>> detailsByTaskBatchSeq = new LinkedHashMap<>();
        for (OrderDetlPakout detail : details) {
            if (!isPendingPakoutDetail(detail)) {
                continue;
            }
            String taskBatchSeq = resolvePakoutTaskBatchSeq(orderNo, detail);
            // LinkedHashMap 保持首次出现顺序,保证同一订单内的批次生成顺序和 ERP 明细顺序一致。
            // 这对高站点尤其关键:第二个 entryWmsCode 只有在第一个批次满足释放条件后才会被处理。
            detailsByTaskBatchSeq
                    .computeIfAbsent(taskBatchSeq, key -> new ArrayList<>())
                    .add(detail);
        }
        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 = 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, taskBatchSeq, 0, candidate.getValue().size(), blockMsg));
        }
        List<OutTaskParam> outTaskParams = new ArrayList<>();
        for (OrderDetlPakout detail : candidate.getValue()) {
            outTaskParams.add(buildOutTaskParam(orderNo, taskBatchSeq, detail));
        }
        Map<String, List<OutTaskParam>> linesByBatchSeq = new LinkedHashMap<>();
        // 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);
        }
        for (OrderDetlPakout detail : candidate.getValue()) {
            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());
            if (!orderDetlPakoutService.updateById(detail)) {
                throw new CoolException("更新出库订单明细作业数量失败:" + orderNo);
            }
        }
        if (Objects.equals(order.getSettle(), 1L)) {
            // 订单一旦生成过任务即置为 2。是否还有明细未生成,仍以明细 anfme - work_qty 判断。
            order.setSettle(2L);
            order.setUpdateBy(9527L);
            order.setUpdateTime(new Date());
            if (!orderPakoutService.updateById(order)) {
                throw new CoolException("更新出库订单状态失败:" + orderNo);
            }
        }
        // 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) {
        if (param == null || Cools.isEmpty(param.getOrderId())) {
            throw new CoolException("出库单号不能为空");
        }
        if (Cools.isEmpty(param.getPalletId())) {
            throw new CoolException("托盘号不能为空");
        }
        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 (Cools.isEmpty(param.getBatchSeq())) {
            // batchSeq 是接口原字段,保存在订单明细用于追溯;实际任务批次键由 resolvePakoutTaskBatchSeq 决定。
            param.setBatchSeq(param.getOrderId());
        }
    }
    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:至少生成过一个任务批次,后续是否还有待生成明细继续看 anfme - work_qty;
        // - 大于 2:其他业务流程已处理/完成,不允许本接口覆盖。
        // status 是启动开关:
        // - ERP /outOrder 创建 executable=true,status=1,定时器可自动生成;
        // - IoT/MQTT 创建 executable=false,status=0,必须调用执行接口后才生成。
        order.setSettle(1L);
        order.setStatus(executable ? 1 : 0);
        order.setCreateBy(9527L);
        order.setCreateTime(now);
        order.setUpdateBy(9527L);
        order.setUpdateTime(now);
        // moveStatus 保留现有“备货/移库”语义,这里不复用它做暂停开关。
        order.setMoveStatus(0);
        // 2 表示出库方向,和 man_order_detl_pakout 明细保持一致。
        order.setPakinPakoutStatus(2);
        if (!orderPakoutService.insert(order)) {
            throw new CoolException("生成出库订单失败:" + orderNo);
        }
        return order;
    }
    private void refreshPendingPakoutOrderForResubmit(OrderPakout order, Date now, boolean executable) {
        if (order == null) {
            return;
        }
        boolean hasDispatchedDetail = hasDispatchedPendingDetail(order.getId());
        // 重复下发时按本次入口重新决定是否立即可执行:
        // 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);
        order.setPakinPakoutStatus(2);
        if (!orderPakoutService.updateById(order)) {
            throw new CoolException("更新出库订单失败:" + order.getOrderNo());
        }
    }
    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));
        if (Cools.isEmpty(details)) {
            return false;
        }
        for (OrderDetlPakout detail : details) {
            if (!isUndispatchedPendingDetail(detail)) {
                return true;
            }
        }
        return false;
    }
    /**
     * 删除同订单中尚未下发的明细。
     *
     * 判断口径:
     * - work_qty <= 0:尚未生成有效任务,或中止后任务已被取消并完成回滚。
     * - qty <= 0:没有业务完成量,删除后不会丢失已完成出库记录。
     */
    private int removeUndispatchedPendingDetails(Long orderId) {
        List<OrderDetlPakout> details = orderDetlPakoutService.selectList(new EntityWrapper<OrderDetlPakout>()
                .eq("order_id", orderId)
                .eq("status", 1));
        if (Cools.isEmpty(details)) {
            return 0;
        }
        int removed = 0;
        for (OrderDetlPakout detail : details) {
            if (!isUndispatchedPendingDetail(detail)) {
                continue;
            }
            if (!orderDetlPakoutService.deleteById(detail.getId())) {
                throw new CoolException("删除未下发出库订单明细失败:" + detail.getOrderNo());
            }
            removed++;
        }
        return removed;
    }
    private void assertNoNonReplaceablePendingDetailConflict(OrderPakout order, List<OutTaskParam> params) {
        if (order == null || Cools.isEmpty(params)) {
            return;
        }
        Set<String> newPalletIds = params.stream()
                .filter(Objects::nonNull)
                .map(OutTaskParam::getPalletId)
                .filter(palletId -> !Cools.isEmpty(palletId))
                .collect(Collectors.toCollection(LinkedHashSet::new));
        if (newPalletIds.isEmpty()) {
            return;
        }
        List<OrderDetlPakout> details = orderDetlPakoutService.selectList(new EntityWrapper<OrderDetlPakout>()
                .eq("order_id", order.getId())
                .eq("status", 1));
        if (Cools.isEmpty(details)) {
            return;
        }
        for (OrderDetlPakout detail : details) {
            // 本次重发如果包含了已经下发/完成的同托盘明细,直接拒绝。
            // 未下发明细可以删除重建;已下发/完成明细必须保留,以免破坏取消回滚和完成回写。
            if (detail == null || !newPalletIds.contains(detail.getPalletId()) || isUndispatchedPendingDetail(detail)) {
                continue;
            }
            throw new CoolException("托盘「" + detail.getPalletId() + "」已存在已下发或已完成出库明细,无法覆盖");
        }
    }
    private boolean isUndispatchedPendingDetail(OrderDetlPakout detail) {
        // “未下发”不是看订单状态,而是看明细进度:
        // work_qty=0 且 qty=0 才说明既没有生成任务,也没有完成量,可以安全删除重建。
        return detail != null
                && safeDouble(detail.getWorkQty()) <= 0.0D
                && safeDouble(detail.getQty()) <= 0.0D;
    }
    /**
     * 判断延迟出库订单是否还能被当前接口请求覆盖。
     *
     * 活动任务仍然存在时不允许覆盖,因为任务、库位、WCS 指令还在执行链路上。
     * 已经取消并归档到 WrkMastLog 的任务允许重下发;其订单明细 work_qty 已在取消时回滚,
     * 本次接口会删除未下发明细并插入新明细。
     */
    private void assertPendingPakoutOrderCanReplace(String orderNo) {
        List<WrkMast> activeTasks = findActiveOutboundTasks(orderNo);
        if (!Cools.isEmpty(activeTasks)) {
            throw new CoolException("出库订单已存在活动任务,无法覆盖:" + orderNo);
        }
        int activeWrkCount = wrkMastService.selectCount(new EntityWrapper<WrkMast>()
                .eq("io_type", 101)
                .eq("user_no", orderNo));
        if (activeWrkCount > 0) {
            throw new CoolException("出库订单已存在任务档,无法覆盖:" + orderNo);
        }
        OrderPakout order = orderPakoutService.selectByNo(orderNo);
        if (order != null && order.getSettle() != null && order.getSettle() > 2L) {
            throw new CoolException(orderNo + "正在出库,无法修改单据");
        }
    }
    /**
     * 把 OutTaskParam 落成出库订单明细。
     *
     * detail.sync(locDetl) 负责复制库存维度字段(物料、批次、品牌、扩展字段等);
     * 后续 setXxx 保存接口原始字段,保证定时器可以还原完整 OutTaskParam。
     */
    private OrderDetlPakout buildPendingPakoutOrderDetl(OrderPakout order, OutTaskParam param, Date now) {
        LocDetl locDetl = locDetlService.selectOne(new EntityWrapper<LocDetl>().eq("zpallet", param.getPalletId()));
        if (locDetl == null) {
            throw new CoolException("库存中不存在该托盘:" + param.getPalletId());
        }
        if (!Cools.isEmpty(param.getMatnr()) && !Objects.equals(param.getMatnr(), locDetl.getMatnr())) {
            throw new CoolException("托盘「" + param.getPalletId() + "」物料编码与库存不一致");
        }
        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);
        detail.setCreateBy(9527L);
        detail.setCreateTime(now);
        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());
        detail.setStationId(param.getStationId());
        detail.setEntryWmsCode(param.getEntryWmsCode());
        detail.setContainerNo(param.getContainerNo());
        detail.setOutDoorNo(param.getOutDoorNo());
        detail.setPlateNo(param.getPlateNo());
        detail.setTrainNo(param.getTrainNo());
        detail.setFreqType(param.getFreqType());
        detail.setCubeNumber(param.getCubeNumber());
        detail.setTeu(param.getTeu());
        // standby1/standby2 继续镜像进仓编号和出库门号,兼容现有完成回写的 selectItem 匹配逻辑。
        // 低站点允许 entryWmsCode 为空,也要显式落空值,保证订单明细和 WrkDetl 维度一致。
        detail.setStandby1(param.getEntryWmsCode());
        detail.setStandby2(param.getOutDoorNo());
        return detail;
    }
    /**
     * 明细数量优先使用 ERP 参数中的 anfme。
     * 如果 ERP 未传数量,则使用当前库存数量;两者都不可用时按整托 1 处理,避免生成 0 数量任务。
     */
    private double resolvePendingOrderAnfme(OutTaskParam param, LocDetl locDetl) {
        if (param.getAnfme() > 0.0D) {
            return param.getAnfme();
        }
        if (locDetl != null && locDetl.getAnfme() != null && locDetl.getAnfme() > 0.0D) {
            return locDetl.getAnfme();
        }
        return 1.0D;
    }
    /**
     * 判断订单明细是否还需要生成任务。
     *
     * work_qty 是任务生成进度,qty 是任务完成进度。
     * 这里只看 anfme - work_qty,避免定时器因为任务未完成而重复生成同一托盘任务。
     */
    private boolean isPendingPakoutDetail(OrderDetlPakout detail) {
        // 只要还存在待生成数量,并且能计算出任务批次键,就认为该明细可进入生成流程。
        // 是否真的生成由后续 OutboundBatchSeqReleaseGuard 决定。
        return detail != null
                && !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,WCS 下发守卫以进仓编号为顺序边界;
     * - 低站点:orderNo,整张 ERP 订单作为一个批次。
     */
    private OutTaskParam buildOutTaskParam(String orderNo, String taskBatchSeq, OrderDetlPakout detail) {
        OutTaskParam param = new OutTaskParam();
        param.setOrderId(orderNo);
        param.setBatchSeq(taskBatchSeq);
        param.setSeq(detail.getSeq());
        param.setPalletId(detail.getPalletId());
        param.setStationId(detail.getStationId());
        param.setMatnr(detail.getMatnr());
        param.setAnfme(getPendingDetailQty(detail));
        param.setEntryWmsCode(detail.getEntryWmsCode());
        param.setContainerNo(detail.getContainerNo());
        param.setOutDoorNo(detail.getOutDoorNo());
        param.setPlateNo(detail.getPlateNo());
        param.setTrainNo(detail.getTrainNo());
        param.setFreqType(detail.getFreqType());
        param.setCubeNumber(detail.getCubeNumber());
        param.setTeu(detail.getTeu());
        return param;
    }
    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("batchSeq", taskBatchSeq);
        // 保留旧 key,避免外部日志或页面仍按 entryWmsCode 读取高站点批次。
        result.put("entryWmsCode", taskBatchSeq);
        result.put("generatedTaskCount", generatedTaskCount);
        result.put("pendingDetailCount", pendingDetailCount);
        if (!Cools.isEmpty(reason)) {
            result.put("reason", reason);
        }
        return result;
    }
    private boolean isPendingOutOrderStation(String stationId) {
        if (Cools.isEmpty(stationId)) {
            return false;
        }
        try {
            return Integer.valueOf(stationId) > PENDING_OUT_ORDER_STATION_THRESHOLD;
        } catch (NumberFormatException ignored) {
            return false;
        }
    }
    private double getPendingDetailQty(OrderDetlPakout detail) {
        return safeDouble(detail.getAnfme()) - safeDouble(detail.getWorkQty());
    }
    private double safeDouble(Double value) {
        return value == null ? 0.0D : value;
    }
    /**
     * 7.9 出库异常变动上报。
     * WCS 只传 palletId、errorMsg,WMS 解析 orderId 后转发 ERP。
     */
    @Override
    public R outOrderAbnormalReport(OutOrderAbnormalReportParam param) {
        if (param == null || Cools.isEmpty(param.getPalletId())) {
            return R.error("palletId不能为空");
        }
        String orderId = resolveOutboundOrderId(param.getPalletId());
        if (Cools.isEmpty(orderId)) {
            return R.error("未找到托盘对应出库单号:" + param.getPalletId());
        }
        if (Cools.isEmpty(erpOutErrorAddress)) {
            return R.error("ERP出库异常上报地址未配置");
        }
        ErpOutOrderAbnormalReportParam erpParam = new ErpOutOrderAbnormalReportParam();
        erpParam.setPalletId(param.getPalletId());
        erpParam.setErrorMsg(param.getErrorMsg());
        erpParam.setOrderId(orderId);
        String requestJson = JSON.toJSONString(erpParam);
        String response = "";
        boolean pushOk = false;
        String errorMsg = null;
        String pushUrl = buildErpOutErrorRequestUrl();
        try {
            response = new HttpHandler.Builder()
                    .setUri(erpUrl)
                    .setPath(erpOutErrorAddress)
                    .setJson(requestJson)
                    .build()
                    .doPost();
            pushOk = isErpCallSuccess(response);
            if (!pushOk) {
                errorMsg = extractErpCallError(response);
            }
        } catch (Exception e) {
            errorMsg = e.getMessage();
            log.error("推ERP出库异常上报失败, palletId={}, orderId={}", param.getPalletId(), orderId, e);
        } finally {
            try {
                apiLogService.save(
                        "推ERP-出库异常上报",
                        pushUrl,
                        null,
                        "127.0.0.1",
                        requestJson,
                        response,
                        pushOk,
                        "palletId=" + param.getPalletId() + ",orderId=" + orderId
                );
            } catch (Exception logEx) {
                log.error("save out abnormal api log failed", logEx);
            }
        }
        if (!pushOk) {
            return R.error(Cools.isEmpty(errorMsg) ? "ERP出库异常上报失败" : errorMsg);
        }
        return R.ok().add(Cools.add("palletId", param.getPalletId()).add("orderId", orderId));
    }
    /**
     * 7.10 出库异常变动处理。
     * 本期只接收、校验和记录,不改本地任务,也不转发 WCS。
     */
    @Override
    public R outOrderAbnormalHandle(OutOrderAbnormalHandleParam param) {
        if (param == null || Cools.isEmpty(param.getPalletId())) {
            return R.error("palletId不能为空");
        }
        if (param.getOperateType() == null || !(Objects.equals(param.getOperateType(), 0) || Objects.equals(param.getOperateType(), 2))) {
            return R.error("operateType只允许0或2");
        }
        if (!existsOutboundPallet(param.getPalletId())) {
            return R.error("未找到托盘:" + param.getPalletId());
        }
        log.info("接收出库异常处理指令, palletId={}, operateType={}", param.getPalletId(), param.getOperateType());
        return R.ok().add(Cools.add("palletId", param.getPalletId()).add("operateType", param.getOperateType()));
    }
    private String buildOutOrderBatchKey(OutTaskParam param) {
        return param.getOrderId() + "#" + param.getBatchSeq();
    }
    private Map<String, Integer> buildOutOrderBatchTeuCounts(List<OutTaskParam> params) {
        Map<String, Set<String>> batchOrderIds = new HashMap<>();
        for (OutTaskParam param : params) {
            batchOrderIds.computeIfAbsent(param.getBatchSeq(), k -> new LinkedHashSet<>()).add(param.getOrderId());
        }
        Map<String, Integer> batchTeuCounts = new HashMap<>();
        for (Map.Entry<String, Set<String>> entry : batchOrderIds.entrySet()) {
            batchTeuCounts.put(entry.getKey(), entry.getValue().size());
        }
        return batchTeuCounts;
    }
    private String resolveOutboundOrderId(String palletId) {
        List<WrkMast> wrkMasts = wrkMastService.selectList(new EntityWrapper<WrkMast>()
                .eq("io_type", 101)
                .eq("barcode", palletId)
                .orderBy("io_time", false)
                .orderBy("wrk_no", false));
        if (!Cools.isEmpty(wrkMasts)) {
            for (WrkMast wrkMast : wrkMasts) {
                if (!Cools.isEmpty(wrkMast.getUserNo())) {
                    return wrkMast.getUserNo();
                }
            }
        }
        List<WrkDetl> wrkDetls = wrkDetlService.selectList(new EntityWrapper<WrkDetl>()
                .eq("zpallet", palletId)
                .orderBy("io_time", false)
                .orderBy("wrk_no", false));
        if (!Cools.isEmpty(wrkDetls)) {
            for (WrkDetl wrkDetl : wrkDetls) {
                if (!Cools.isEmpty(wrkDetl.getOrderNo())) {
                    return wrkDetl.getOrderNo();
                }
            }
        }
        return null;
    }
    private boolean existsOutboundPallet(String palletId) {
        if (locDetlService.selectCount(new EntityWrapper<LocDetl>().eq("zpallet", palletId)) > 0) {
            return true;
        }
        if (wrkMastService.selectCount(new EntityWrapper<WrkMast>().eq("io_type", 101).eq("barcode", palletId)) > 0) {
            return true;
        }
        return wrkDetlService.selectCount(new EntityWrapper<WrkDetl>().eq("zpallet", palletId)) > 0;
    }
    private boolean isErpCallSuccess(String response) {
        if (Cools.isEmpty(response)) {
            return false;
        }
        try {
            JSONObject jsonObject = JSON.parseObject(response);
            if (jsonObject == null) {
                return false;
            }
            Boolean success = jsonObject.getBoolean("success");
            if (success != null) {
                return success;
            }
            Integer code = jsonObject.getInteger("code");
            if (code != null) {
                return code == 200 || code == 0;
            }
            Integer status = jsonObject.getInteger("status");
            if (status != null) {
                return status == 200 || status == 0;
            }
            String result = jsonObject.getString("result");
            if (!Cools.isEmpty(result)) {
                return "success".equalsIgnoreCase(result) || "ok".equalsIgnoreCase(result) || "true".equalsIgnoreCase(result);
            }
        } catch (Exception ignore) {
            return response.toLowerCase().contains("success") && response.toLowerCase().contains("true");
        }
        return false;
    }
    private String extractErpCallError(String response) {
        if (Cools.isEmpty(response)) {
            return "empty erp response";
        }
        try {
            JSONObject jsonObject = JSON.parseObject(response);
            if (jsonObject == null) {
                return response;
            }
            String[] keys = new String[]{"msg", "message", "error", "errMsg"};
            for (String key : keys) {
                String value = jsonObject.getString(key);
                if (!Cools.isEmpty(value)) {
                    return value;
                }
            }
        } catch (Exception ignore) {
        }
        return response;
    }
    private boolean isErpReportEnabled() {
        if (!erpReportOld) {
            return false;
        }
        String erpReport = Parameter.get().getErpReport();
        return Cools.isEmpty(erpReport) || "true".equalsIgnoreCase(erpReport);
    }
    private List<String> normalizeBarcodes(List<String> barcodes) {
        if (barcodes == null || barcodes.isEmpty()) {
            return Collections.emptyList();
        }
        LinkedHashSet<String> normalized = new LinkedHashSet<>();
        for (String barcode : barcodes) {
            if (barcode == null) {
                continue;
            }
            String value = barcode.trim();
            if (!value.isEmpty()) {
                normalized.add(value);
            }
        }
        return new ArrayList<>(normalized);
    }
    private Map<String, WrkMastLog> loadLatestInboundHistoryLogMap(List<String> barcodes) {
        Map<String, WrkMastLog> latestInboundLogMap = new LinkedHashMap<>();
        if (barcodes == null || barcodes.isEmpty()) {
            return latestInboundLogMap;
        }
        List<WrkMastLog> wrkMastLogs = wrkMastLogService.selectList(new EntityWrapper<WrkMastLog>()
                .in("barcode", barcodes)
                .orderBy("barcode", true)
                .orderBy("io_time", false)
                .orderBy("wrk_no", false));
        if (Cools.isEmpty(wrkMastLogs)) {
            return latestInboundLogMap;
        }
        for (WrkMastLog wrkMastLog : wrkMastLogs) {
            if (wrkMastLog == null || Cools.isEmpty(wrkMastLog.getBarcode())) {
                continue;
            }
            if (!isInboundHistoryLog(wrkMastLog.getIoType())) {
                continue;
            }
            latestInboundLogMap.putIfAbsent(wrkMastLog.getBarcode(), wrkMastLog);
        }
        return latestInboundLogMap;
    }
    private boolean isInboundHistoryLog(Integer ioType) {
        if (ioType == null) {
            return false;
        }
        // 历史表里既有入库完成,也有库存调整;这里只取真正的入库类记录。
        return ioType < 19 || ioType == 53 || ioType == 54 || ioType == 57;
    }
    private Map<Integer, List<WrkDetlLog>> loadWrkDetlLogMap(Collection<WrkMastLog> wrkMastLogs) {
        Map<Integer, List<WrkDetlLog>> wrkDetlLogMap = new HashMap<>();
        if (wrkMastLogs == null || wrkMastLogs.isEmpty()) {
            return wrkDetlLogMap;
        }
        LinkedHashSet<Integer> wrkNos = new LinkedHashSet<>();
        for (WrkMastLog wrkMastLog : wrkMastLogs) {
            if (wrkMastLog != null && wrkMastLog.getWrkNo() != null) {
                wrkNos.add(wrkMastLog.getWrkNo());
            }
        }
        if (wrkNos.isEmpty()) {
            return wrkDetlLogMap;
        }
        List<WrkDetlLog> wrkDetlLogs = wrkDetlLogService.selectList(new EntityWrapper<WrkDetlLog>().in("wrk_no", wrkNos));
        if (Cools.isEmpty(wrkDetlLogs)) {
            return wrkDetlLogMap;
        }
        for (WrkDetlLog wrkDetlLog : wrkDetlLogs) {
            if (wrkDetlLog == null || wrkDetlLog.getWrkNo() == null) {
                continue;
            }
            wrkDetlLogMap.computeIfAbsent(wrkDetlLog.getWrkNo(), k -> new ArrayList<>()).add(wrkDetlLog);
        }
        return wrkDetlLogMap;
    }
    private Map<String, WaitPakinLog> loadWaitPakinLogMap(List<String> barcodes) {
        Map<String, WaitPakinLog> waitPakinLogMap = new LinkedHashMap<>();
        if (barcodes == null || barcodes.isEmpty()) {
            return waitPakinLogMap;
        }
        List<WaitPakinLog> waitPakinLogs = waitPakinLogService.selectList(new EntityWrapper<WaitPakinLog>()
                .in("zpallet", barcodes)
                .orderBy("zpallet", true)
                .orderBy("appe_time", false)
                .orderBy("modi_time", false));
        if (Cools.isEmpty(waitPakinLogs)) {
            return waitPakinLogMap;
        }
        for (WaitPakinLog waitPakinLog : waitPakinLogs) {
            if (waitPakinLog == null || Cools.isEmpty(waitPakinLog.getZpallet())) {
                continue;
            }
            waitPakinLogMap.putIfAbsent(waitPakinLog.getZpallet(), waitPakinLog);
        }
        return waitPakinLogMap;
    }
    private ErpPakinReportParam buildInboundErpParam(String barcode,
                                                     WrkMastLog wrkMastLog,
                                                     List<WrkDetlLog> wrkDetlLogs,
                                                     WaitPakinLog waitPakinLog) {
        ErpPakinReportParam param = new ErpPakinReportParam();
        param.setPalletId(resolvePalletId(barcode, wrkMastLog, wrkDetlLogs, waitPakinLog));
        param.setAnfme(resolveInboundAnfme(wrkDetlLogs, waitPakinLog));
        param.setLocId(transformInboundLocId(resolveInboundLocNo(wrkMastLog, waitPakinLog)));
        param.setWeight(resolveInboundWeight(wrkMastLog, waitPakinLog));
        param.setCreateTime(formatDate(resolveInboundCreateTime(wrkMastLog)));
        param.setBizNo(resolveInboundBizNo(wrkDetlLogs, waitPakinLog));
        param.setStartTime(formatDate(resolveInboundStartTime(wrkMastLog, waitPakinLog)));
        return param;
    }
    private String resolvePalletId(String barcode,
                                   WrkMastLog wrkMastLog,
                                   List<WrkDetlLog> wrkDetlLogs,
                                   WaitPakinLog waitPakinLog) {
        if (wrkMastLog != null && !Cools.isEmpty(wrkMastLog.getBarcode())) {
            return wrkMastLog.getBarcode();
        }
        if (wrkDetlLogs != null) {
            for (WrkDetlLog wrkDetlLog : wrkDetlLogs) {
                if (wrkDetlLog != null && !Cools.isEmpty(wrkDetlLog.getZpallet())) {
                    return wrkDetlLog.getZpallet();
                }
            }
        }
        if (waitPakinLog != null && !Cools.isEmpty(waitPakinLog.getZpallet())) {
            return waitPakinLog.getZpallet();
        }
        return barcode;
    }
    private Double resolveInboundAnfme(List<WrkDetlLog> wrkDetlLogs, WaitPakinLog waitPakinLog) {
        double total = 0D;
        boolean hasDetail = false;
        if (wrkDetlLogs != null) {
            for (WrkDetlLog wrkDetlLog : wrkDetlLogs) {
                if (wrkDetlLog == null || wrkDetlLog.getAnfme() == null) {
                    continue;
                }
                total += wrkDetlLog.getAnfme();
                hasDetail = true;
            }
        }
        if (hasDetail) {
            return total;
        }
        if (waitPakinLog != null && waitPakinLog.getAnfme() != null) {
            return waitPakinLog.getAnfme();
        }
        return total;
    }
    private BigDecimal resolveInboundWeight(WrkMastLog wrkMastLog, WaitPakinLog waitPakinLog) {
        BigDecimal baseWeight = BigDecimal.ZERO;
        if (wrkMastLog != null && wrkMastLog.getScWeight() != null) {
            baseWeight = BigDecimal.valueOf(wrkMastLog.getScWeight());
        } else if (waitPakinLog != null && waitPakinLog.getWeight() != null) {
            baseWeight = BigDecimal.valueOf(waitPakinLog.getWeight());
        }
        Integer sourceStaNo = resolveInboundSourceStaNo(wrkMastLog);
        return baseWeight.multiply(resolveInboundWeightFactor(sourceStaNo));
    }
    private Integer resolveInboundSourceStaNo(WrkMastLog wrkMastLog) {
        if (wrkMastLog == null) {
            return null;
        }
        if (wrkMastLog.getSourceStaNo() != null) {
            return wrkMastLog.getSourceStaNo();
        }
        return wrkMastLog.getStaNo();
    }
    private BigDecimal resolveInboundWeightFactor(Integer sourceStaNo) {
        if (sourceStaNo == null) {
            return BigDecimal.ONE;
        }
        BigDecimal factor = INBOUND_WEIGHT_FACTOR_BY_SOURCE_STA.get(sourceStaNo);
        return factor == null ? BigDecimal.ONE : factor;
    }
    private String resolveInboundLocNo(WrkMastLog wrkMastLog, WaitPakinLog waitPakinLog) {
        if (wrkMastLog != null && !Cools.isEmpty(wrkMastLog.getLocNo())) {
            return wrkMastLog.getLocNo();
        }
        if (waitPakinLog != null && !Cools.isEmpty(waitPakinLog.getLocNo())) {
            return waitPakinLog.getLocNo();
        }
        return null;
    }
    private Date resolveInboundCreateTime(WrkMastLog wrkMastLog) {
        if (wrkMastLog == null) {
            return new Date();
        }
        if (wrkMastLog.getCrnEndTime() != null) {
            return wrkMastLog.getCrnEndTime();
        }
        if (wrkMastLog.getModiTime() != null) {
            return wrkMastLog.getModiTime();
        }
        if (wrkMastLog.getIoTime() != null) {
            return wrkMastLog.getIoTime();
        }
        return new Date();
    }
    private Date resolveInboundStartTime(WrkMastLog wrkMastLog, WaitPakinLog waitPakinLog) {
        if (waitPakinLog != null && waitPakinLog.getAppeTime() != null) {
            return waitPakinLog.getAppeTime();
        }
        if (wrkMastLog == null) {
            return new Date();
        }
        if (wrkMastLog.getAppeTime() != null) {
            return wrkMastLog.getAppeTime();
        }
        if (wrkMastLog.getIoTime() != null) {
            return wrkMastLog.getIoTime();
        }
        if (wrkMastLog.getModiTime() != null) {
            return wrkMastLog.getModiTime();
        }
        return new Date();
    }
    private String resolveInboundBizNo(List<WrkDetlLog> wrkDetlLogs, WaitPakinLog waitPakinLog) {
        if (wrkDetlLogs != null) {
            for (WrkDetlLog wrkDetlLog : wrkDetlLogs) {
                if (wrkDetlLog != null && !Cools.isEmpty(wrkDetlLog.getThreeCode())) {
                    return wrkDetlLog.getThreeCode();
                }
            }
        }
        if (waitPakinLog != null && !Cools.isEmpty(waitPakinLog.getThreeCode())) {
            return waitPakinLog.getThreeCode();
        }
        return null;
    }
    private String transformInboundLocId(String locId) {
        if (Cools.isEmpty(locId)) {
            return null;
        }
        String trimmed = locId.trim();
        if (trimmed.length() < 7) {
            return trimmed;
        }
        String row = trimmed.substring(0, 2);
        String col = trimmed.substring(2, 5);
        String lev = trimmed.substring(5, 7);
        try {
            int rowNo = Integer.parseInt(row);
            if (rowNo >= 37) {
                row = "C" + row;
            } else if (rowNo >= 13) {
                row = "B" + row;
            } else {
                row = "A" + row;
            }
        } catch (Exception ignore) {
            return trimmed;
        }
        return row + "-" + col + "-" + lev;
    }
    private String formatDate(Date date) {
        if (date == null) {
            return null;
        }
        return new SimpleDateFormat(DATE_TIME_PATTERN).format(date);
    }
    private String buildErpInboundRequestUrl() {
        if (Cools.isEmpty(erpUrl)) {
            return erpInAddress;
        }
        if (erpInAddress == null) {
            return erpUrl;
        }
        if (erpUrl.endsWith("/") && erpInAddress.startsWith("/")) {
            return erpUrl + erpInAddress.substring(1);
        }
        if (!erpUrl.endsWith("/") && !erpInAddress.startsWith("/")) {
            return erpUrl + "/" + erpInAddress;
        }
        return erpUrl + erpInAddress;
    }
    private String buildErpOutErrorRequestUrl() {
        if (Cools.isEmpty(erpUrl)) {
            return erpOutErrorAddress;
        }
        if (erpOutErrorAddress == null) {
            return erpUrl;
        }
        if (erpUrl.endsWith("/") && erpOutErrorAddress.startsWith("/")) {
            return erpUrl + erpOutErrorAddress.substring(1);
        }
        if (!erpUrl.endsWith("/") && !erpOutErrorAddress.startsWith("/")) {
            return erpUrl + "/" + erpOutErrorAddress;
        }
        return erpUrl + erpOutErrorAddress;
    }
}