cl
6 天以前 2a34b52125d5fc356d65ee1e8912845dd601d4e3
多加入参数和修改规则
4个文件已添加
26个文件已修改
1323 ■■■■■ 已修改文件
rsf-admin/src/page/orders/asnOrder/AsnOrderList.jsx 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/orders/outStock/OutOrderList.jsx 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/api/config/RemotesInfoProperties.java 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/api/controller/erp/params/DapIlcwmsCompletionLine.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/api/controller/erp/params/InOutResultBatchPayload.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/api/controller/erp/params/InOutResultReportParam.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/api/controller/erp/params/SyncOrderParams.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/api/service/CloudWmsReportService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/api/service/impl/CloudWmsReportServiceImpl.java 88 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/api/service/impl/ReceiveMsgServiceImpl.java 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/common/service/RedisService.java 139 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/constant/CloudWmsInoutReportMode.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/OutStockController.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/WkOrderController.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/entity/CloudWmsNotifyLog.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/entity/WkOrder.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/schedules/CloudWmsInoutAggSchedule.java 134 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/schedules/CloudWmsNotifySchedule.java 214 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/service/CloudWmsNotifyLogService.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/service/impl/CloudWmsFeedbackResendServiceImpl.java 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/service/impl/CloudWmsNotifyLogServiceImpl.java 268 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/service/impl/CloudWmsOrderTaskRunningHelper.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/service/impl/TaskServiceImpl.java 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/system/config/ConfigCacheProperties.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/system/constant/GlobalConfigCode.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/system/service/ConfigService.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/system/service/impl/ConfigServiceImpl.java 9 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/resources/application-dev.yml 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/resources/application-prod.yml 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/resources/application.yml 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/orders/asnOrder/AsnOrderList.jsx
@@ -51,6 +51,7 @@
import CloseIcon from '@mui/icons-material/Close';
import EditIcon from '@mui/icons-material/Edit';
import TaskIcon from '@mui/icons-material/Task';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import { styled } from '@mui/material/styles';
import AsnOrderModal from "./AsnOrderModal";
import request from '@/utils/request';
@@ -181,6 +182,7 @@
            <MyButton setCreateDialog={setCreateDialog} setmodalType={setmodalType} />
            {/* <InspectionButton /> 报检按钮暂不使用 */}
            <CompleteButton />
            <CloudWmsAsnReportButton />
            <ODeleteButton />
            <PrintButton setPrintOrder={setPrintOrder} setSelect={setSelect} />
            {/* <CloseButton /> */}
@@ -213,6 +215,31 @@
}
export default AsnOrderList;
/** manual:放行该单暂缓的云仓上报待办(send_hold);wait_order 一般由调度放行 */
const CloudWmsAsnReportButton = () => {
  const record = useRecordContext();
  const notify = useNotify();
  const refresh = useRefresh();
  const onClick = async (e) => {
    e.stopPropagation();
    try {
      const res = await request.post('/asnOrder/cloudWmsReport/submit', { code: record?.code });
      const { code, msg } = res.data || {};
      if (code === 200) {
        notify(msg || '已提交', { type: 'success' });
        refresh();
      } else {
        notify(msg || '操作失败', { type: 'warning' });
      }
    } catch (err) {
      notify(err?.message || '请求失败', { type: 'warning' });
    }
  };
  return (
    <Button label="云仓上报" size="small" onClick={onClick} startIcon={<CloudUploadIcon />}/>
  );
};
//按PO单新建
const CreateByPoButton = ({ setPoCreate }) => {
  const record = useRecordContext();
rsf-admin/src/page/orders/outStock/OutOrderList.jsx
@@ -59,6 +59,7 @@
import TaskIcon from '@mui/icons-material/Task';
import OutOrderPreview from "./OutOrderPreview";
import AddIcon from '@mui/icons-material/Add';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import OutStockPublic from "./OutStockPublic";
import OutOrderModal from "./OutOrderModal";
import request from '@/utils/request';
@@ -328,10 +329,35 @@
      {isInit && <MyButton setCreateDialog={setCreateDialog} setmodalType={setmodalType} />}
      <EditButton label="toolbar.detail" icon={(<DetailsIcon />)} />
      <PublicButton setDrawerVal={setDrawerVal} drawerVal={drawerVal} setSelect={setSelect} />
      <CloudWmsOutReportButton />
    </>
  );
};
const CloudWmsOutReportButton = () => {
  const record = useRecordContext();
  const notify = useNotify();
  const refresh = useRefresh();
  const onClick = async (e) => {
    e.stopPropagation();
    try {
      const res = await request.post('/outStock/cloudWmsReport/submit', { code: record?.code });
      const { code, msg } = res.data || {};
      if (code === 200) {
        notify(msg || '已提交', { type: 'success' });
        refresh();
      } else {
        notify(msg || '操作失败', { type: 'warning' });
      }
    } catch (err) {
      notify(err?.message || '请求失败', { type: 'warning' });
    }
  };
  return (
    <Button label="云仓上报" size="small" onClick={onClick} startIcon={<CloudUploadIcon />} />
  );
};
const MyButton = ({ setCreateDialog, setmodalType }) => {
  const record = useRecordContext();
  const handleEditClick = (btn) => {
rsf-server/src/main/java/com/vincent/rsf/server/api/config/RemotesInfoProperties.java
@@ -36,7 +36,7 @@
    private String baseUrl;
    /**
     * 鼎捷 ilcwmsplus 完成反馈等公共字段(orgNo、单据类别、单位)
     * 鼎捷 ilcwmsplus 完成反馈等公共字段(单据类别、单位等)
     */
    private Dap dap = new Dap();
@@ -47,7 +47,6 @@
    @Data
    public static class Dap {
        private String orgNo = "";
        private String docTypeIn = "";
        private String docTypeOut = "";
        /** 库存调整(9.2)单据类别;移库等 */
rsf-server/src/main/java/com/vincent/rsf/server/api/controller/erp/params/DapIlcwmsCompletionLine.java
@@ -15,6 +15,8 @@
public class DapIlcwmsCompletionLine {
    private String orgNo;
    /** 任务仓库(与云仓通知单 docWarehouseNo 一致时可单独传递) */
    private String docWarehouseNo;
    private String docType;
    private String docNo;
    private String docSeqNo;
rsf-server/src/main/java/com/vincent/rsf/server/api/controller/erp/params/InOutResultBatchPayload.java
New file
@@ -0,0 +1,16 @@
package com.vincent.rsf.server.api.controller.erp.params;
import lombok.Data;
import lombok.experimental.Accessors;
import java.util.List;
/**
 * 云仓 9.1 入出库结果:一单多行合并上报时的请求体(与单条 InOutResultReportParam 区分)
 */
@Data
@Accessors(chain = true)
public class InOutResultBatchPayload {
    private List<InOutResultReportParam> lines;
}
rsf-server/src/main/java/com/vincent/rsf/server/api/controller/erp/params/InOutResultReportParam.java
@@ -31,6 +31,18 @@
    @ApiModelProperty(value = "仓库编码", required = true)
    private String wareHouseId;
    @ApiModelProperty("任务仓库(通知单存储,回馈云仓)")
    private String docWarehouseNo;
    @ApiModelProperty("组织编码(通知单存储,回馈云仓)")
    private String orgNo;
    @ApiModelProperty("入库仓编码(入库通知单存储,回馈 inWarehouseNo)")
    private String inWarehouseNo;
    @ApiModelProperty("出库仓编码(出库通知单存储,回馈 outWarehouseNo)")
    private String outWarehouseNo;
    @ApiModelProperty(value = "库位号", required = true)
    private String locId;
rsf-server/src/main/java/com/vincent/rsf/server/api/controller/erp/params/SyncOrderParams.java
@@ -1,6 +1,7 @@
package com.vincent.rsf.server.api.controller.erp.params;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
@@ -34,6 +35,19 @@
    @ApiModelProperty("入/出库接驳站点")
    private String stationId;
    @ApiModelProperty("任务仓库(与云仓 JSON 字段 docWarehouseNo 对应)")
    @JsonProperty("docWarehouseNo")
    private String docTaskWarehouseNo;
    @ApiModelProperty("组织编码(orgNo)")
    private String orgNo;
    @ApiModelProperty("入库仓编码(inWarehouseNo),入库通知单")
    private String inWarehouseNo;
    @ApiModelProperty("出库仓编码(outWarehouseNo),出库通知单")
    private String outWarehouseNo;
    @ApiModelProperty("原库位")
    private String orgLoc;
rsf-server/src/main/java/com/vincent/rsf/server/api/service/CloudWmsReportService.java
@@ -3,6 +3,7 @@
import com.vincent.rsf.server.api.controller.erp.params.InOutResultReportParam;
import com.vincent.rsf.server.api.controller.erp.params.InventoryAdjustReportParam;
import java.util.List;
import java.util.Map;
/**
@@ -25,6 +26,11 @@
    Map<String, Object> reportInOutResult(InOutResultReportParam param);
    /**
     * 9.1 入出库结果合并上报(一单多行)
     */
    Map<String, Object> reportInOutResults(List<InOutResultReportParam> lines);
    /**
     * 9.2 库存调整主动上报(立库侧调用云仓通知)
     * @param param 上报参数
     * @return 云仓返回结构 Map:code, msg, data(data.result 为 SUCCESS/FAIL)
rsf-server/src/main/java/com/vincent/rsf/server/api/service/impl/CloudWmsReportServiceImpl.java
@@ -27,6 +27,11 @@
@Service
public class CloudWmsReportServiceImpl implements CloudWmsReportService {
    /** 云仓回馈:通知单 orgNo 为空时的默认组织 */
    private static final String DEFAULT_CLOUD_ORG_NO = "1";
    /** 云仓回馈:通知单 inWarehouseNo / outWarehouseNo 为空时的默认仓编码 */
    private static final String DEFAULT_CLOUD_WH_NO = "101";
    @Autowired
    private RemotesInfoProperties erpApi;
@@ -38,7 +43,7 @@
    @Override
    public Map<String, Object> syncMatnrsToCloud(Object body) {
        if (!isCloudWmsConfigured()) {
            log.warn("ErpApi(云仓WMS) 未配置 host,跳过物料基础信息同步");
            log.warn("ErpApi(云仓WMS) 未配置 host/base-url,跳过物料基础信息同步");
            return stubSuccess("云仓地址未配置,未实际同步");
        }
        return cloudWmsErpFeignClient.syncMatnrs(body != null ? body : new HashMap<>());
@@ -50,10 +55,10 @@
            return resultMap(400, "参数不能为空", null);
        }
        if (!isCloudWmsConfigured()) {
            log.warn("ErpApi(云仓WMS) 未配置 host,跳过 9.1 入/出库结果上报,订单:{}", param.getOrderNo());
            log.warn("ErpApi(云仓WMS) 未配置 host/base-url,跳过 9.1 入/出库结果上报,订单:{}", param.getOrderNo());
            return stubSuccess("云仓地址未配置,未实际上报");
        }
        String err = validateDapBase();
        String err = validateDapBaseForInOut(param);
        if (err != null) {
            return resultMap(400, err, null);
        }
@@ -68,17 +73,46 @@
    }
    @Override
    public Map<String, Object> reportInOutResults(List<InOutResultReportParam> lines) {
        if (lines == null || lines.isEmpty()) {
            return resultMap(400, "明细不能为空", null);
        }
        if (!isCloudWmsConfigured()) {
            log.warn("ErpApi(云仓WMS) 未配置 host/base-url,跳过 9.1 入出库合并上报");
            return stubSuccess("云仓地址未配置,未实际上报");
        }
        InOutResultReportParam first = lines.get(0);
        boolean inbound = first.getInbound() == null || Boolean.TRUE.equals(first.getInbound());
        for (InOutResultReportParam param : lines) {
            String err = validateDapBaseForInOut(param);
            if (err != null) {
                return resultMap(400, err, null);
            }
            boolean rowIn = param.getInbound() == null || Boolean.TRUE.equals(param.getInbound());
            if (rowIn != inbound) {
                return resultMap(400, "合并上报须同为入库或同为出库", null);
            }
        }
        List<DapIlcwmsCompletionLine> data = new ArrayList<>(lines.size());
        for (InOutResultReportParam param : lines) {
            data.add(buildInOutLine(param, inbound));
        }
        DapIlcwmsCompletionRequest req = new DapIlcwmsCompletionRequest().setData(data);
        logOutboundPayload("IN_OUT_RESULT_BATCH", inbound ? "stockInCompleted" : "stockOutCompleted", req);
        Map<String, Object> raw = inbound
                ? cloudWmsErpFeignClient.cusInventoryCompletionReport(req)
                : cloudWmsErpFeignClient.cusOutboundCompletionReport(req);
        return DapIlcwmsResponseNormalizer.toNotifyFormat(raw);
    }
    @Override
    public Map<String, Object> reportInventoryAdjust(InventoryAdjustReportParam param) {
        if (param == null) {
            return resultMap(400, "参数不能为空", null);
        }
        if (!isCloudWmsConfigured()) {
            log.warn("ErpApi(云仓WMS) 未配置 host,跳过 9.2 库存调整上报,物料:{}", param.getMatNr());
            log.warn("ErpApi(云仓WMS) 未配置 host/base-url,跳过 9.2 库存调整上报,物料:{}", param.getMatNr());
            return stubSuccess("云仓地址未配置,未实际上报");
        }
        String err = validateDapBase();
        if (err != null) {
            return resultMap(400, err, null);
        }
        Integer changeType = param.getChangeType();
        if (changeType == null) {
@@ -107,15 +141,26 @@
                : DapIlcwmsResponseNormalizer.toNotifyFormatFlexible(raw);
    }
    private String validateDapBaseForInOut(InOutResultReportParam param) {
        if (param != null && StringUtils.isBlank(param.getUnitNo())) {
            return "unitNo 不能为空";
        }
        return null;
    }
    private DapIlcwmsCompletionLine buildInOutLine(InOutResultReportParam param, boolean inbound) {
        RemotesInfoProperties.Dap dap = erpApi.getDap();
        CloudMatnrParts matnrParts = parseCloudMatnr(param.getMatNr());
        String docType = StringUtils.isNotBlank(param.getWkType())
                ? param.getWkType()
                : (inbound ? "IN" : "OUT");
        String unitNo = StringUtils.isNotBlank(param.getUnitNo()) ? param.getUnitNo() : "PCS";
        String unitNo = StringUtils.trimToEmpty(param.getUnitNo());
        String orgNoLine = StringUtils.isNotBlank(param.getOrgNo()) ? param.getOrgNo().trim() : DEFAULT_CLOUD_ORG_NO;
        String docWh = StringUtils.trimToNull(param.getDocWarehouseNo());
        String inWhLine = StringUtils.isNotBlank(param.getInWarehouseNo()) ? param.getInWarehouseNo().trim() : DEFAULT_CLOUD_WH_NO;
        String outWhLine = StringUtils.isNotBlank(param.getOutWarehouseNo()) ? param.getOutWarehouseNo().trim() : DEFAULT_CLOUD_WH_NO;
        DapIlcwmsCompletionLine line = new DapIlcwmsCompletionLine()
                .setOrgNo(dap.getOrgNo())
                .setOrgNo(orgNoLine)
                .setDocWarehouseNo(docWh)
                .setDocType(docType)
                .setDocNo(param.getOrderNo())
                .setDocSeqNo(StringUtils.isNotBlank(param.getLineId()) ? param.getLineId() : "1")
@@ -126,9 +171,9 @@
                .setCombinationLotNo(matnrParts.getCombinationLotNo())
                .setBarcode(matnrParts.getBarcode());
        if (inbound) {
            line.setInWarehouseNo(param.getWareHouseId()).setInCellNo(param.getLocId());
            line.setInWarehouseNo(inWhLine).setInCellNo(param.getLocId());
        } else {
            line.setOutWarehouseNo(param.getWareHouseId()).setOutCellNo(param.getLocId());
            line.setOutWarehouseNo(outWhLine).setOutCellNo(param.getLocId());
        }
        return line;
    }
@@ -139,7 +184,6 @@
     * @param docSeqOverride 非空时用作项次(移库第二行等)
     */
    private DapIlcwmsCompletionLine buildAdjustLine(InventoryAdjustReportParam param, boolean fillIn, boolean fillOut, String docSeqOverride) {
        RemotesInfoProperties.Dap dap = erpApi.getDap();
        String docType = resolveAdjustDocType(param);
        String docNo = StringUtils.isNotBlank(param.getDocNo()) ? param.getDocNo() : "ADJ";
        String docSeq = docSeqOverride != null ? docSeqOverride
@@ -147,7 +191,7 @@
        String unit = StringUtils.isNotBlank(param.getUnitNo()) ? param.getUnitNo() : "PCS";
        CloudMatnrParts matnrParts = parseCloudMatnr(param.getMatNr());
        DapIlcwmsCompletionLine line = new DapIlcwmsCompletionLine()
                .setOrgNo(dap.getOrgNo())
                .setOrgNo(DEFAULT_CLOUD_ORG_NO)
                .setDocType(docType)
                .setDocNo(docNo)
                .setDocSeqNo(docSeq)
@@ -254,17 +298,13 @@
        }
    }
    private String validateDapBase() {
        RemotesInfoProperties.Dap d = erpApi.getDap();
        if (d == null || StringUtils.isBlank(d.getOrgNo())) {
            return "未配置 platform.erp.dap.org-no";
        }
        return null;
    }
    private boolean isCloudWmsConfigured() {
        String host = erpApi.getHost();
        return host != null && !host.trim().isEmpty();
        if (host != null && !host.trim().isEmpty()) {
            return true;
        }
        String baseUrl = erpApi.getBaseUrl();
        return baseUrl != null && !baseUrl.trim().isEmpty();
    }
    private Map<String, Object> stubSuccess(String msg) {
rsf-server/src/main/java/com/vincent/rsf/server/api/service/impl/ReceiveMsgServiceImpl.java
@@ -607,6 +607,19 @@
                .setUpdateTime(new Date())
                .setCreateBy(loginUserId)
                .setUpdateBy(loginUserId);
        if (StringUtils.isNotBlank(syncOrder.getDocTaskWarehouseNo())) {
            wkOrder.setDocTaskWarehouseNo(syncOrder.getDocTaskWarehouseNo().trim());
        }
        if (StringUtils.isNotBlank(syncOrder.getOrgNo())) {
            wkOrder.setDocOrgNo(syncOrder.getOrgNo().trim());
        }
        String effTypeForDoc = resolvedOrderType != null ? resolvedOrderType : StringUtils.trimToNull(syncOrder.getType());
        if (OrderType.ORDER_IN.type.equals(effTypeForDoc) && StringUtils.isNotBlank(syncOrder.getInWarehouseNo())) {
            wkOrder.setDocInWarehouseNo(syncOrder.getInWarehouseNo().trim());
        }
        if (OrderType.ORDER_OUT.type.equals(effTypeForDoc) && StringUtils.isNotBlank(syncOrder.getOutWarehouseNo())) {
            wkOrder.setDocOutWarehouseNo(syncOrder.getOutWarehouseNo().trim());
        }
        if (resolvedOrderType != null && resolvedOrderType.equals(OrderType.ORDER_OUT.type)) {
            wkOrder.setExceStatus(AsnExceStatus.OUT_STOCK_STATUS_TASK_INIT.val);
@@ -781,6 +794,26 @@
                return true;
            }
        }
        if (StringUtils.isNotBlank(syncOrder.getOrgNo())) {
            if (!StringUtils.equals(syncOrder.getOrgNo().trim(), StringUtils.trimToNull(order.getDocOrgNo()))) {
                return true;
            }
        }
        if (StringUtils.isNotBlank(syncOrder.getDocTaskWarehouseNo())) {
            if (!StringUtils.equals(syncOrder.getDocTaskWarehouseNo().trim(), StringUtils.trimToNull(order.getDocTaskWarehouseNo()))) {
                return true;
            }
        }
        if (OrderType.ORDER_IN.type.equals(order.getType()) && StringUtils.isNotBlank(syncOrder.getInWarehouseNo())) {
            if (!StringUtils.equals(syncOrder.getInWarehouseNo().trim(), StringUtils.trimToNull(order.getDocInWarehouseNo()))) {
                return true;
            }
        }
        if (OrderType.ORDER_OUT.type.equals(order.getType()) && StringUtils.isNotBlank(syncOrder.getOutWarehouseNo())) {
            if (!StringUtils.equals(syncOrder.getOutWarehouseNo().trim(), StringUtils.trimToNull(order.getDocOutWarehouseNo()))) {
                return true;
            }
        }
        return false;
    }
@@ -887,6 +920,18 @@
        if (StringUtils.isNotBlank(syncOrder.getStationId())) {
            order.setStationId(syncOrder.getStationId());
        }
        if (StringUtils.isNotBlank(syncOrder.getDocTaskWarehouseNo())) {
            order.setDocTaskWarehouseNo(syncOrder.getDocTaskWarehouseNo().trim());
        }
        if (StringUtils.isNotBlank(syncOrder.getOrgNo())) {
            order.setDocOrgNo(syncOrder.getOrgNo().trim());
        }
        if (OrderType.ORDER_IN.type.equals(order.getType()) && StringUtils.isNotBlank(syncOrder.getInWarehouseNo())) {
            order.setDocInWarehouseNo(syncOrder.getInWarehouseNo().trim());
        }
        if (OrderType.ORDER_OUT.type.equals(order.getType()) && StringUtils.isNotBlank(syncOrder.getOutWarehouseNo())) {
            order.setDocOutWarehouseNo(syncOrder.getOutWarehouseNo().trim());
        }
        order.setUpdateBy(loginUserId);
        order.setUpdateTime(new Date());
        asnOrderService.updateById(order);
@@ -972,6 +1017,18 @@
        if (StringUtils.isNotBlank(syncOrder.getStationId())) {
            order.setStationId(syncOrder.getStationId());
        }
        if (StringUtils.isNotBlank(syncOrder.getDocTaskWarehouseNo())) {
            order.setDocTaskWarehouseNo(syncOrder.getDocTaskWarehouseNo().trim());
        }
        if (StringUtils.isNotBlank(syncOrder.getOrgNo())) {
            order.setDocOrgNo(syncOrder.getOrgNo().trim());
        }
        if (OrderType.ORDER_IN.type.equals(order.getType()) && StringUtils.isNotBlank(syncOrder.getInWarehouseNo())) {
            order.setDocInWarehouseNo(syncOrder.getInWarehouseNo().trim());
        }
        if (OrderType.ORDER_OUT.type.equals(order.getType()) && StringUtils.isNotBlank(syncOrder.getOutWarehouseNo())) {
            order.setDocOutWarehouseNo(syncOrder.getOutWarehouseNo().trim());
        }
        order.setUpdateBy(loginUserId);
        order.setUpdateTime(new Date());
        asnOrderService.updateById(order);
rsf-server/src/main/java/com/vincent/rsf/server/common/service/RedisService.java
@@ -3,6 +3,7 @@
import com.vincent.rsf.common.utils.Serialize;
import com.vincent.rsf.server.common.config.RedisProperties;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import redis.clients.jedis.Jedis;
@@ -36,16 +37,19 @@
            JedisPoolConfig config = new JedisPoolConfig();
            config.setTestOnBorrow(false);
            this.index = redisProperties.getIndex();
            // 空白密码传 null,不向未开认证的 Redis 发 AUTH
            String pwd = StringUtils.trimToNull(redisProperties.getPassword());
            this.pool = new JedisPool(config
                    , redisProperties.getHost()
                    , redisProperties.getPort()
                    , redisProperties.getTimeout()
                    , redisProperties.getPassword()
                    , pwd
            );
        }
        return this.pool;
    }
    /** 借连接失败时返回 null */
    public Jedis getJedis(){
        try{
            Jedis jedis = this.getPool().getResource();
@@ -71,6 +75,9 @@
            return null;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return null;
        }
        try{
            return jedis.set((flag + LINK + key).getBytes(), Serialize.serialize(value));
        } catch (Exception e) {
@@ -88,6 +95,9 @@
            return null;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return null;
        }
        try{
            return jedis.setex((flag + LINK + key).getBytes(), seconds, Serialize.serialize(value));
        } catch (Exception e) {
@@ -101,6 +111,9 @@
            return null;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return null;
        }
        try{
            byte[] bytes = jedis.get((flag + LINK + key).getBytes());
            if(bytes == null || bytes.length == 0 ) {
@@ -118,6 +131,9 @@
            return null;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return null;
        }
        try{
            return jedis.del((flag + LINK + key).getBytes());
        } catch (Exception e) {
@@ -131,6 +147,9 @@
            return null;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return null;
        }
        this.setValue(flag, "CLEARING", "true");
        try{
            Object returnValue = jedis.eval("local keys = redis.call('keys', ARGV[1]) for i=1,#keys,1000 do redis.call('del', unpack(keys, i, math.min(i+4999, #keys))) end return #keys",0,flag + LINK + "*");
@@ -147,6 +166,9 @@
            return;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return;
        }
        try{
            jedis.expire((flag + LINK + key).getBytes(), seconds);
        } catch (Exception e) {
@@ -160,6 +182,9 @@
            return;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return;
        }
        try{
            jedis.expireAt((flag + LINK + key).getBytes(), toTime.getTime()/1000);
        } catch (Exception e) {
@@ -173,6 +198,9 @@
            return null;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return null;
        }
        try{
            return jedis.ttl((flag + LINK + key).getBytes());
        } catch (Exception e) {
@@ -189,6 +217,9 @@
            return null;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return null;
        }
        try{
            return jedis.set(flag + LINK + key, value);
        } catch (Exception e) {
@@ -202,6 +233,9 @@
            return null;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return null;
        }
        try{
            return jedis.setex(flag + LINK + key, seconds , value);
        } catch (Exception e) {
@@ -215,6 +249,9 @@
            return null;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return null;
        }
        try{
            return jedis.get(flag + LINK + key);
        } catch (Exception e) {
@@ -229,6 +266,9 @@
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return null;
        }
        try{
            String[] keys = new String[key.length];
            for(int i=0;i<key.length;i++){
@@ -248,6 +288,9 @@
        this.setValue(flag, "CLEARING", "true");
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return null;
        }
        try{
            Object returnValue = jedis.eval("return redis.call('del', unpack(redis.call('keys', ARGV[1])))",0,flag + LINK + "*");
@@ -263,6 +306,9 @@
            return;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return;
        }
        try{
            jedis.expire((flag + LINK + key).getBytes(), seconds);
        } catch (Exception e) {
@@ -275,6 +321,9 @@
            return;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return;
        }
        try{
            jedis.expireAt((flag + LINK + key).getBytes(), atTime.getTime()/1000);
        } catch (Exception e) {
@@ -294,6 +343,9 @@
            return null;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return null;
        }
        try {
            return jedis.hset(name.getBytes(), key.getBytes(), Serialize.serialize(value));
        } catch (Exception e) {
@@ -307,6 +359,9 @@
            return null;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return null;
        }
        try{
            byte[] bytes = jedis.hget(name.getBytes(), key.getBytes());
            if (bytes == null || bytes.length == 0) {
@@ -324,6 +379,9 @@
            return null;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return null;
        }
        try{
            return jedis.hkeys(name);
        } catch (Exception e) {
@@ -337,6 +395,9 @@
            return null;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return null;
        }
        try{
            String[] keys = new String[key.length];
            System.arraycopy(key, 0, keys, 0, key.length);
@@ -352,6 +413,9 @@
            return null;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return null;
        }
        try{
            return jedis.del(name);
        } catch (Exception e) {
@@ -365,6 +429,9 @@
            return;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return;
        }
        try{
            jedis.expire(name.getBytes(), seconds);
        } catch (Exception e) {
@@ -377,6 +444,9 @@
            return;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return;
        }
        try{
            jedis.expireAt(name.getBytes(), atTime.getTime()/1000);
        } catch (Exception e) {
@@ -396,6 +466,9 @@
            return null;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return null;
        }
        try{
            return jedis.rpush(name.getBytes(), Serialize.serialize(value));
        } catch (Exception e) {
@@ -410,6 +483,9 @@
            return null;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return null;
        }
        try{
            byte[] bytes = jedis.lpop(name.getBytes());
            if(bytes == null || bytes.length == 0) {
@@ -428,6 +504,9 @@
            return null;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return null;
        }
        try{
            return jedis.del(name);
        } catch (Exception e) {
@@ -441,6 +520,9 @@
            return;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return;
        }
        try{
            jedis.expire(name.getBytes(), seconds);
        } catch (Exception e) {
@@ -453,6 +535,9 @@
            return;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return;
        }
        try{
            jedis.expireAt(name.getBytes(), atTime.getTime()/1000);
        } catch (Exception e) {
@@ -467,6 +552,9 @@
            return null;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return null;
        }
        try{
            return jedis.incr("COUNT." + key);
        } catch (Exception e) {
@@ -480,6 +568,9 @@
            return null;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return null;
        }
        try{
            return jedis.decr("COUNT." + key);
        } catch (Exception e) {
@@ -488,4 +579,50 @@
        return null;
    }
    /** SET NX EX;键已存在返回 false;未初始化或借连接失败返回 true,由调用方降级 */
    public boolean trySetStringNxEx(String flag, String key, String value, int expireSeconds) {
        if (!this.initialize) {
            return true;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return true;
        }
        try {
            String fullKey = flag + LINK + key;
            String r = jedis.set(fullKey, value, "NX", "EX", expireSeconds);
            return "OK".equals(r);
        } catch (Exception e) {
            log.error(this.getClass().getSimpleName(), e);
            return true;
        }
    }
    /** SETNX + EXPIRE;未抢到锁返回 false;jedis 异常时降级为 true */
    public boolean tryLockValue(String flag, String key, int expireSeconds) {
        if(!this.initialize) {
            return true;
        }
        Jedis jedis = this.getJedis();
        if (jedis == null) {
            return true;
        }
        try {
            String lockKey = flag + LINK + key;
            Long r = jedis.setnx(lockKey, "1");
            if (r != null && r == 1L) {
                jedis.expire(lockKey, expireSeconds);
                return true;
            }
            return false;
        } catch (Exception e) {
            log.error(this.getClass().getSimpleName(), e);
            return true;
        }
    }
    public void unlockValue(String flag, String key) {
        deleteValue(flag, key);
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/manager/constant/CloudWmsInoutReportMode.java
New file
@@ -0,0 +1,19 @@
package com.vincent.rsf.server.manager.constant;
/**
 * 云仓入出库回馈模式(sys_config CLOUD_WMS_INOUT_REPORT_MODE,val 小写)
 */
public final class CloudWmsInoutReportMode {
    /** 按任务行立即落待办(与改造前一致) */
    public static final String IMMEDIATE = "immediate";
    /** 实绩写入 notify 待办且暂缓发送;整单无执行中任务且防抖后放行,或超过「无操作秒数」放行 */
    public static final String WAIT_ORDER = "wait_order";
    /** 实绩写入 notify 待办且暂缓发送,由入库/出库通知单「上报云仓」放行发送 */
    public static final String MANUAL = "manual";
    private CloudWmsInoutReportMode() {
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/OutStockController.java
@@ -26,6 +26,7 @@
import com.vincent.rsf.server.manager.controller.params.AsnOrderAndItemsParams;
import com.vincent.rsf.server.manager.entity.excel.AsnOrderTemplate;
import com.vincent.rsf.server.manager.enums.AsnExceStatus;
import com.vincent.rsf.server.manager.service.CloudWmsNotifyLogService;
import com.vincent.rsf.server.manager.service.OutStockItemService;
import com.vincent.rsf.server.manager.service.OutStockService;
import com.vincent.rsf.server.system.constant.SerialRuleCode;
@@ -52,6 +53,16 @@
    private OutStockService outStockService;
    @Autowired
    private OutStockItemService outStockItemService;
    @Autowired
    private CloudWmsNotifyLogService cloudWmsNotifyLogService;
    @ApiOperation("手动触发云仓回馈(出库通知单,放行暂缓上报)")
    @PostMapping("/outStock/cloudWmsReport/submit")
    @PreAuthorize("hasAuthority('manager:outStock:list')")
    public R submitCloudWmsReportOutbound(@RequestBody(required = false) Map<String, Object> body) {
        String code = body != null && body.get("code") != null ? String.valueOf(body.get("code")).trim() : null;
        return cloudWmsNotifyLogService.manualFlushToNotifyByOrderCode(code, false);
    }
    @PreAuthorize("hasAuthority('manager:outStock:list')")
    @PostMapping("/outStock/page")
rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/WkOrderController.java
@@ -21,6 +21,7 @@
import com.vincent.rsf.server.manager.entity.excel.AsnOrderTemplate;
import com.vincent.rsf.server.manager.service.AsnOrderItemService;
import com.vincent.rsf.server.manager.service.AsnOrderService;
import com.vincent.rsf.server.manager.service.CloudWmsNotifyLogService;
import com.vincent.rsf.server.manager.service.impl.PurchaseItemServiceImpl;
import com.vincent.rsf.server.manager.service.impl.PurchaseServiceImpl;
import com.vincent.rsf.server.system.constant.SerialRuleCode;
@@ -48,6 +49,16 @@
    private PurchaseServiceImpl purchaseService;
    @Autowired
    private PurchaseItemServiceImpl purchaseItemService;
    @Autowired
    private CloudWmsNotifyLogService cloudWmsNotifyLogService;
    @ApiOperation("手动触发云仓回馈(入库通知单,放行暂缓上报)")
    @PostMapping("/asnOrder/cloudWmsReport/submit")
    @PreAuthorize("hasAuthority('manager:asnOrder:list')")
    public R submitCloudWmsReportInbound(@RequestBody(required = false) Map<String, Object> body) {
        String code = body != null && body.get("code") != null ? String.valueOf(body.get("code")).trim() : null;
        return cloudWmsNotifyLogService.manualFlushToNotifyByOrderCode(code, true);
    }
    @PreAuthorize("hasAuthority('manager:asnOrder:list')")
    @PostMapping("/asnOrder/page")
rsf-server/src/main/java/com/vincent/rsf/server/manager/entity/CloudWmsNotifyLog.java
@@ -69,6 +69,21 @@
    @ApiModelProperty("业务关联(如 taskId、reviseLogId,便于排查)")
    private String bizRef;
    @ApiModelProperty("云仓单据号(入出库索引)")
    private String sourceOrderNo;
    @ApiModelProperty("1入库 0出库")
    private Integer inboundFlag;
    @ApiModelProperty("云仓仓库编码")
    private String wareHouseCode;
    @ApiModelProperty("1暂缓调度发送(manual/wait_order 未放行)")
    private Integer sendHold;
    @ApiModelProperty("1正在上报中,合并任务排除")
    private Integer sending;
    @ApiModelProperty("租户")
    private Integer tenantId;
rsf-server/src/main/java/com/vincent/rsf/server/manager/entity/WkOrder.java
@@ -66,6 +66,26 @@
    @com.baomidou.mybatisplus.annotation.TableField("station_id")
    private String stationId;
    /** 云仓下发:任务仓库(与回馈报文 docWarehouseNo 一致) */
    @ApiModelProperty("任务仓库")
    @com.baomidou.mybatisplus.annotation.TableField("doc_task_warehouse_no")
    private String docTaskWarehouseNo;
    /** 云仓下发:组织编码(回馈 orgNo) */
    @ApiModelProperty("组织编码")
    @com.baomidou.mybatisplus.annotation.TableField("doc_org_no")
    private String docOrgNo;
    /** 云仓下发:入库仓编码(回馈 inWarehouseNo) */
    @ApiModelProperty("入库仓编码")
    @com.baomidou.mybatisplus.annotation.TableField("doc_in_warehouse_no")
    private String docInWarehouseNo;
    /** 云仓下发:出库仓编码(回馈 outWarehouseNo) */
    @ApiModelProperty("出库仓编码")
    @com.baomidou.mybatisplus.annotation.TableField("doc_out_warehouse_no")
    private String docOutWarehouseNo;
    /**
     * 单据类型
     */
rsf-server/src/main/java/com/vincent/rsf/server/manager/schedules/CloudWmsInoutAggSchedule.java
New file
@@ -0,0 +1,134 @@
package com.vincent.rsf.server.manager.schedules;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.vincent.rsf.server.manager.constant.CloudWmsInoutReportMode;
import com.vincent.rsf.server.manager.entity.CloudWmsNotifyLog;
import com.vincent.rsf.server.manager.service.CloudWmsNotifyLogService;
import com.vincent.rsf.server.manager.service.impl.CloudWmsOrderTaskRunningHelper;
import com.vincent.rsf.server.system.constant.GlobalConfigCode;
import com.vincent.rsf.server.system.entity.Config;
import com.vincent.rsf.server.system.service.ConfigService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
 * wait_order:按空闲/整单无执行中任务 + 防抖,将暂缓发送的入出库待办放行(数据在 man_cloud_wms_notify_log)
 */
@Slf4j
@Component
public class CloudWmsInoutAggSchedule {
    private static final int DEFAULT_IDLE_SEC = 180;
    private static final int DEFAULT_DEBOUNCE_SEC = 8;
    @Autowired
    private CloudWmsNotifyLogService cloudWmsNotifyLogService;
    @Autowired
    private CloudWmsOrderTaskRunningHelper cloudWmsOrderTaskRunningHelper;
    @Autowired
    private ConfigService configService;
    @Scheduled(cron = "0/15 * * * * ?")
    public void releaseHeldInOutNotifies() {
        String mode = resolveMode();
        if (!CloudWmsInoutReportMode.WAIT_ORDER.equals(mode)) {
            return;
        }
        int idleSec = resolveInt(GlobalConfigCode.CLOUD_WMS_INOUT_AGG_IDLE_SECONDS, DEFAULT_IDLE_SEC);
        int debounceSec = resolveInt(GlobalConfigCode.CLOUD_WMS_INOUT_AGG_COMPLETE_DEBOUNCE_SECONDS, DEFAULT_DEBOUNCE_SEC);
        long nowMs = System.currentTimeMillis();
        String rt = cloudWmsNotifyLogService.getReportTypeInOutResult();
        int pending = cloudWmsNotifyLogService.getNotifyStatusPending();
        int fail = cloudWmsNotifyLogService.getNotifyStatusFail();
        List<CloudWmsNotifyLog> heldRows = cloudWmsNotifyLogService.list(new LambdaQueryWrapper<CloudWmsNotifyLog>()
                .eq(CloudWmsNotifyLog::getReportType, rt)
                .eq(CloudWmsNotifyLog::getSendHold, 1)
                .in(CloudWmsNotifyLog::getNotifyStatus, pending, fail)
                .isNotNull(CloudWmsNotifyLog::getSourceOrderNo)
                .apply("(max_retry_count IS NULL OR max_retry_count = -1 OR retry_count < max_retry_count)"));
        if (heldRows.isEmpty()) {
            return;
        }
        Map<String, List<CloudWmsNotifyLog>> groups = new LinkedHashMap<>();
        for (CloudWmsNotifyLog row : heldRows) {
            if (row == null) {
                continue;
            }
            String key = row.getSourceOrderNo() + "\t" + StringUtils.defaultString(String.valueOf(row.getInboundFlag()))
                    + "\t" + StringUtils.defaultString(row.getWareHouseCode());
            groups.computeIfAbsent(key, k -> new ArrayList<>()).add(row);
        }
        Date now = new Date();
        for (List<CloudWmsNotifyLog> group : groups.values()) {
            if (group.isEmpty()) {
                continue;
            }
            CloudWmsNotifyLog first = group.get(0);
            long maxUt = 0L;
            for (CloudWmsNotifyLog r : group) {
                Date u = r.getUpdateTime() != null ? r.getUpdateTime() : r.getCreateTime();
                if (u != null) {
                    maxUt = Math.max(maxUt, u.getTime());
                }
            }
            long ageSec = maxUt <= 0 ? 0 : Math.max(0L, (nowMs - maxUt) / 1000L);
            boolean running = cloudWmsOrderTaskRunningHelper.hasRunningTasksForPlatOrder(first.getSourceOrderNo());
            boolean idleFlush = ageSec >= idleSec;
            boolean completeFlush = !running && ageSec >= debounceSec;
            if (!idleFlush && !completeFlush) {
                continue;
            }
            List<Long> ids = new ArrayList<>(group.size());
            for (CloudWmsNotifyLog r : group) {
                if (r.getId() != null) {
                    ids.add(r.getId());
                }
            }
            if (ids.isEmpty()) {
                continue;
            }
            try {
                LambdaUpdateWrapper<CloudWmsNotifyLog> u = new LambdaUpdateWrapper<>();
                u.in(CloudWmsNotifyLog::getId, ids)
                        .set(CloudWmsNotifyLog::getSendHold, 0)
                        .set(CloudWmsNotifyLog::getUpdateTime, now);
                cloudWmsNotifyLogService.update(u);
            } catch (Exception e) {
                log.warn("云仓 wait_order 放行待办异常:{}", e.getMessage());
            }
        }
    }
    private String resolveMode() {
        try {
            Config c = configService.getCachedOrLoad(GlobalConfigCode.CLOUD_WMS_INOUT_REPORT_MODE);
            if (c != null && StringUtils.isNotBlank(c.getVal())) {
                return c.getVal().trim().toLowerCase();
            }
        } catch (Exception ignored) {
        }
        return CloudWmsInoutReportMode.IMMEDIATE;
    }
    private int resolveInt(String flag, int defaultVal) {
        try {
            Config c = configService.getCachedOrLoad(flag);
            if (c != null && StringUtils.isNotBlank(c.getVal())) {
                int v = Integer.parseInt(c.getVal().trim());
                return v >= 0 ? v : defaultVal;
            }
        } catch (Exception ignored) {
        }
        return defaultVal;
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/manager/schedules/CloudWmsNotifySchedule.java
@@ -1,7 +1,9 @@
package com.vincent.rsf.server.manager.schedules;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.vincent.rsf.server.api.controller.erp.params.InOutResultBatchPayload;
import com.vincent.rsf.server.api.controller.erp.params.InOutResultReportParam;
import com.vincent.rsf.server.api.controller.erp.params.InventoryAdjustReportParam;
import com.vincent.rsf.server.api.service.CloudWmsReportService;
@@ -16,10 +18,14 @@
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.Set;
/** 云仓上报定时任务 */
@Slf4j
@@ -38,51 +44,175 @@
    @Autowired
    private ConfigService configService;
    @Scheduled(cron = "0/30 * * * * ?")
    /** sending=1 且 Redis 占位已失、update_time 超时:补偿清零 */
    @Scheduled(cron = "0 0/2 * * * ?")
//    @Scheduled(cron = "0/5 * * * * ?")
    public void recoverStaleSending() {
        try {
            cloudWmsNotifyLogService.recoverStaleSendingWhenRedisMiss();
        } catch (Exception e) {
            log.warn("云仓 sending 补偿任务异常:{}", e.getMessage());
        }
    }
    @Scheduled(cron = "0/60 * * * * ?")
    public void syncCloudWmsNotify() {
        // List<CloudWmsNotifyLog> pending = cloudWmsNotifyLogService.listPending(BATCH_LIMIT, 999);
        List<CloudWmsNotifyLog> pending = cloudWmsNotifyLogService.listPending(BATCH_LIMIT, -1);
        if (pending.isEmpty()) {
            log.debug("云仓上报调度:本轮待发送 0 条");
            return;
        }
        long nowMs = System.currentTimeMillis();
        List<CloudWmsNotifyLog> ready = pending.stream()
                .filter(logRecord -> shouldProcess(logRecord, nowMs))
                .collect(Collectors.toList());
        ready.parallelStream().forEach(this::safeProcessOne);
        log.info("云仓上报调度:本轮待发送 {} 条", pending.size());
        dispatchPending(pending);
    }
    /** 同单多条合并上报 */
    private void dispatchPending(List<CloudWmsNotifyLog> pending) {
        String rtInOut = cloudWmsNotifyLogService.getReportTypeInOutResult();
        LinkedHashMap<String, List<CloudWmsNotifyLog>> inOutGroups = new LinkedHashMap<>();
        for (CloudWmsNotifyLog row : pending) {
            if (!rtInOut.equals(row.getReportType())) {
                continue;
            }
            String key = cloudWmsNotifyLogService.inOutMergeKeyFromRequestBody(row.getRequestBody());
            if (key == null) {
                continue;
            }
            inOutGroups.computeIfAbsent(key, k -> new ArrayList<>()).add(row);
        }
        Set<Long> done = new HashSet<>();
        for (CloudWmsNotifyLog row : pending) {
            Long rid = row.getId();
            if (rid != null && done.contains(rid)) {
                continue;
            }
            if (!rtInOut.equals(row.getReportType())) {
                safeProcessOne(row);
                if (rid != null) {
                    done.add(rid);
                }
                continue;
            }
            String key = cloudWmsNotifyLogService.inOutMergeKeyFromRequestBody(row.getRequestBody());
            if (key == null) {
                safeProcessOne(row);
                if (rid != null) {
                    done.add(rid);
                }
                continue;
            }
            List<CloudWmsNotifyLog> g = inOutGroups.get(key);
            if (g != null && g.size() >= 2) {
                safeProcessMergedInOutGroup(g);
                for (CloudWmsNotifyLog x : g) {
                    if (x.getId() != null) {
                        done.add(x.getId());
                    }
                }
            } else {
                safeProcessOne(row);
                if (rid != null) {
                    done.add(rid);
                }
            }
        }
    }
    private void safeProcessMergedInOutGroup(List<CloudWmsNotifyLog> group) {
        List<Long> claimedIds = new ArrayList<>();
        try {
            for (CloudWmsNotifyLog row : group) {
                Long id = row.getId();
                if (!cloudWmsNotifyLogService.tryClaimSending(id)) {
                    log.debug("云仓上报同批合并未抢到发送权 id={}", id);
                    return;
                }
                claimedIds.add(id);
            }
            processMergedInOut(group);
        } catch (Exception e) {
            log.warn("云仓上报同批合并异常:{}", e.getMessage());
        } finally {
            for (Long id : claimedIds) {
                cloudWmsNotifyLogService.clearSending(id);
            }
        }
    }
    private void processMergedInOut(List<CloudWmsNotifyLog> group) {
        Date now = new Date();
        List<InOutResultReportParam> lines = new ArrayList<>();
        try {
            for (CloudWmsNotifyLog row : group) {
                lines.addAll(cloudWmsNotifyLogService.parseInOutLinesFromRequestBody(row.getRequestBody()));
            }
        } catch (IOException e) {
            String msg = "反序列化失败: " + e.getMessage();
            for (CloudWmsNotifyLog row : group) {
                int nextRetry = (row.getRetryCount() == null ? 0 : row.getRetryCount()) + 1;
                setFailResult(row, row.getRequestBody(), msg, nextRetry, now, row.getMaxRetryCount());
            }
            return;
        }
        if (lines.isEmpty()) {
            return;
        }
        String mergedBody;
        try {
            mergedBody = objectMapper.writeValueAsString(new InOutResultBatchPayload().setLines(lines));
        } catch (JsonProcessingException e) {
            for (CloudWmsNotifyLog row : group) {
                int nextRetry = (row.getRetryCount() == null ? 0 : row.getRetryCount()) + 1;
                setFailResult(row, row.getRequestBody(), "合并请求体序列化失败: " + e.getMessage(), nextRetry, now, row.getMaxRetryCount());
            }
            return;
        }
        log.info("云仓上报开始(同单合并),ids={},requestBody={}", idsOf(group), mergedBody);
        try {
            Map<String, Object> res = cloudWmsReportService.reportInOutResults(lines);
            for (CloudWmsNotifyLog row : group) {
                int nextRetry = (row.getRetryCount() == null ? 0 : row.getRetryCount()) + 1;
                updateAfterNotify(row, mergedBody, res, nextRetry, now, row.getMaxRetryCount());
            }
        } catch (FeignException e) {
            String responseBody = e.contentUTF8();
            String fullMsg = "status=" + e.status() + ",message=" + e.getMessage()
                    + (responseBody == null || responseBody.isEmpty() ? "" : ",responseBody=" + responseBody);
            log.warn("云仓上报同批合并请求失败:{}", fullMsg);
            for (CloudWmsNotifyLog row : group) {
                int nextRetry = (row.getRetryCount() == null ? 0 : row.getRetryCount()) + 1;
                setFailResult(row, mergedBody, "请求异常: " + fullMsg, nextRetry, now, row.getMaxRetryCount());
            }
        } catch (Exception e) {
            log.warn("云仓上报同批合并请求失败:{}", e.getMessage());
            for (CloudWmsNotifyLog row : group) {
                int nextRetry = (row.getRetryCount() == null ? 0 : row.getRetryCount()) + 1;
                setFailResult(row, mergedBody, "请求异常: " + e.getMessage(), nextRetry, now, row.getMaxRetryCount());
            }
        }
    }
    private static List<Long> idsOf(List<CloudWmsNotifyLog> group) {
        List<Long> ids = new ArrayList<>(group.size());
        for (CloudWmsNotifyLog row : group) {
            ids.add(row.getId());
        }
        return ids;
    }
    private void safeProcessOne(CloudWmsNotifyLog logRecord) {
        Long id = logRecord.getId();
        if (!cloudWmsNotifyLogService.tryClaimSending(id)) {
            log.debug("云仓上报未抢到发送权 id={}", id);
            return;
        }
        try {
            processOne(logRecord);
        } catch (Exception e) {
            log.warn("云仓上报定时任务处理单条异常,id={},bizRef={}:{}", logRecord.getId(), logRecord.getBizRef(), e.getMessage());
        } finally {
            cloudWmsNotifyLogService.clearSending(id);
        }
    }
    private boolean shouldProcess(CloudWmsNotifyLog logRecord, long nowMs) {
        Integer maxRetry = logRecord.getMaxRetryCount();
        Integer intervalSeconds = logRecord.getRetryIntervalSeconds();
        if (maxRetry == null || intervalSeconds == null) {
            log.warn("云仓上报待办跳过:重试参数缺失,id={},bizRef={},maxRetry={},intervalSeconds={}",
                    logRecord.getId(), logRecord.getBizRef(), maxRetry, intervalSeconds);
            return false;
        }
        if (!isInfiniteRetry(maxRetry)
                && logRecord.getRetryCount() != null
                && logRecord.getRetryCount() >= maxRetry) {
            log.info("云仓上报待办跳过:重试次数已达上限,id={},bizRef={},retryCount={},maxRetry={}",
                    logRecord.getId(), logRecord.getBizRef(), logRecord.getRetryCount(), maxRetry);
            return false;
        }
        int effectiveIntervalSeconds = Math.max(0, intervalSeconds);
        if (logRecord.getLastNotifyTime() != null) {
            long elapsed = (nowMs - logRecord.getLastNotifyTime().getTime()) / 1000;
            if (elapsed < effectiveIntervalSeconds) {
                return false;
            }
        }
        return true;
    }
    private void processOne(CloudWmsNotifyLog logRecord) {
@@ -91,15 +221,24 @@
        Date now = new Date();
        int nextRetry = (logRecord.getRetryCount() == null ? 0 : logRecord.getRetryCount()) + 1;
        int effectiveMaxRetry = logRecord.getMaxRetryCount();
        String rtInOut = cloudWmsNotifyLogService.getReportTypeInOutResult();
        String rtAdj = cloudWmsNotifyLogService.getReportTypeInventoryAdjust();
        log.info("云仓上报开始,id={},bizRef={},reportType={},attempt={},requestBody={}",
                logRecord.getId(), logRecord.getBizRef(), reportType, nextRetry, requestBody);
        try {
            if (cloudWmsNotifyLogService.getReportTypeInOutResult().equals(reportType)) {
            if (rtInOut.equals(reportType)) {
                JsonNode root = objectMapper.readTree(requestBody);
                Map<String, Object> res;
                if (root.has("lines") && root.get("lines").isArray()) {
                    InOutResultBatchPayload batch = objectMapper.readValue(requestBody, InOutResultBatchPayload.class);
                    res = cloudWmsReportService.reportInOutResults(batch.getLines());
                } else {
                InOutResultReportParam param = objectMapper.readValue(requestBody, InOutResultReportParam.class);
                Map<String, Object> res = cloudWmsReportService.reportInOutResult(param);
                    res = cloudWmsReportService.reportInOutResult(param);
                }
                updateAfterNotify(logRecord, requestBody, res, nextRetry, now, effectiveMaxRetry);
            } else if (cloudWmsNotifyLogService.getReportTypeInventoryAdjust().equals(reportType)) {
            } else if (rtAdj.equals(reportType)) {
                InventoryAdjustReportParam param = objectMapper.readValue(requestBody, InventoryAdjustReportParam.class);
                Map<String, Object> res = cloudWmsReportService.reportInventoryAdjust(param);
                updateAfterNotify(logRecord, requestBody, res, nextRetry, now, effectiveMaxRetry);
@@ -138,6 +277,7 @@
        logRecord.setLastNotifyTime(now);
        logRecord.setRetryCount(nextRetry);
        logRecord.setNotifyStatus(status);
        logRecord.setSending(0);
        logRecord.setUpdateTime(now);
        cloudWmsNotifyLogService.updateById(logRecord);
        log.info("云仓上报结束,id={},bizRef={},attempt={},notifyStatus={},responseBody={}",
@@ -149,11 +289,11 @@
        logRecord.setLastResponseBody(truncateForStore(errorMsg));
        logRecord.setLastNotifyTime(now);
        logRecord.setRetryCount(nextRetry);
        // logRecord.setNotifyStatus(nextRetry >= effectiveMaxRetry ? cloudWmsNotifyLogService.getNotifyStatusFail() : cloudWmsNotifyLogService.getNotifyStatusPending());
        int status = !isInfiniteRetry(effectiveMaxRetry) && nextRetry >= effectiveMaxRetry
                ? cloudWmsNotifyLogService.getNotifyStatusFail()
                : cloudWmsNotifyLogService.getNotifyStatusPending();
        logRecord.setNotifyStatus(status);
        logRecord.setSending(0);
        logRecord.setUpdateTime(now);
        cloudWmsNotifyLogService.updateById(logRecord);
        log.warn("云仓上报失败,id={},bizRef={},attempt={},notifyStatus={},error={}",
rsf-server/src/main/java/com/vincent/rsf/server/manager/service/CloudWmsNotifyLogService.java
@@ -1,8 +1,11 @@
package com.vincent.rsf.server.manager.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.vincent.rsf.framework.common.R;
import com.vincent.rsf.server.api.controller.erp.params.InOutResultReportParam;
import com.vincent.rsf.server.manager.entity.CloudWmsNotifyLog;
import java.io.IOException;
import java.util.List;
/** 云仓上报待办 */
@@ -26,4 +29,21 @@
    /** 通知状态:失败(系统配置优先,缺省 2) */
    int getNotifyStatusFail();
    /** 入出库同单分组键(orderNo+入库/出库+仓),与调度内存合并一致 */
    String inOutMergeKeyFromRequestBody(String requestBody);
    /** 解析请求体为入出库行列表(单行 JSON 或 lines 数组) */
    List<InOutResultReportParam> parseInOutLinesFromRequestBody(String requestBody) throws IOException;
    /** 发送前占位,避免与合并并发;返回 false 表示已有实例在处理 */
    boolean tryClaimSending(Long id);
    void clearSending(Long id);
    /** sending=1 且 update_time 超时:补偿清零;启用 Redis 时还要求占位已失 */
    void recoverStaleSendingWhenRedisMiss();
    /** manual 模式:按单号放行暂缓的入出库待办(send_hold=1→0) */
    R manualFlushToNotifyByOrderCode(String orderCode, boolean inbound);
}
rsf-server/src/main/java/com/vincent/rsf/server/manager/service/impl/CloudWmsFeedbackResendServiceImpl.java
@@ -1,8 +1,10 @@
package com.vincent.rsf.server.manager.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.vincent.rsf.framework.common.R;
import com.vincent.rsf.server.api.controller.erp.params.InOutResultBatchPayload;
import com.vincent.rsf.server.api.controller.erp.params.InOutResultReportParam;
import com.vincent.rsf.server.manager.entity.AsnOrderLog;
import com.vincent.rsf.server.manager.entity.CloudWmsNotifyLog;
@@ -59,12 +61,21 @@
            if (StringUtils.isBlank(row.getRequestBody())) {
                continue;
            }
            InOutResultReportParam p;
            try {
                p = objectMapper.readValue(row.getRequestBody(), InOutResultReportParam.class);
            } catch (Exception e) {
                JsonNode root = objectMapper.readTree(row.getRequestBody());
                if (root.has("lines") && root.get("lines").isArray() && root.get("lines").size() > 0) {
                    InOutResultBatchPayload batch = objectMapper.readValue(row.getRequestBody(), InOutResultBatchPayload.class);
                    if (batch.getLines() == null || batch.getLines().isEmpty()) {
                continue;
            }
                    InOutResultReportParam first = batch.getLines().get(0);
                    if (!code.equals(first.getOrderNo()) || !matchOrderType(orderLog.getType(), first.getInbound())) {
                        continue;
                    }
                    latestByLine.putIfAbsent("batch_" + row.getId(), row);
                    continue;
                }
                InOutResultReportParam p = objectMapper.readValue(row.getRequestBody(), InOutResultReportParam.class);
            if (p == null || !code.equals(p.getOrderNo())) {
                continue;
            }
@@ -73,6 +84,9 @@
            }
            String sig = lineSignature(p);
            latestByLine.putIfAbsent(sig, row);
            } catch (Exception e) {
                continue;
            }
        }
        if (latestByLine.isEmpty()) {
            return R.error("未找到该单号对应的云仓入出库上报记录,无法重发");
@@ -87,7 +101,16 @@
                    .setRetryCount(0)
                    .setBizRef("manualResend,asnOrderLogId=" + asnOrderLogId + ",fromNotifyLogId=" + src.getId() + ",orderNo=" + code)
                    .setCreateTime(now)
                    .setUpdateTime(now);
                    .setUpdateTime(now)
                    .setSendHold(0)
                    .setSending(0);
            if (StringUtils.isNotBlank(src.getSourceOrderNo())) {
                copy.setSourceOrderNo(src.getSourceOrderNo())
                        .setInboundFlag(src.getInboundFlag())
                        .setWareHouseCode(src.getWareHouseCode());
            } else {
                fillInOutRoutingFromBody(copy, src.getRequestBody());
            }
            cloudWmsNotifyLogService.fillFromConfig(copy);
            if (cloudWmsNotifyLogService.save(copy)) {
                n++;
@@ -96,6 +119,33 @@
        return R.ok("已加入云仓重发队列 " + n + " 条,将由定时任务发送").add(n);
    }
    private void fillInOutRoutingFromBody(CloudWmsNotifyLog copy, String body) {
        if (StringUtils.isBlank(body)) {
            return;
        }
        try {
            JsonNode root = objectMapper.readTree(body);
            if (root.has("lines") && root.get("lines").isArray() && root.get("lines").size() > 0) {
                InOutResultReportParam first = objectMapper.treeToValue(root.get("lines").get(0), InOutResultReportParam.class);
                if (first != null) {
                    boolean inb = first.getInbound() == null || Boolean.TRUE.equals(first.getInbound());
                    copy.setSourceOrderNo(first.getOrderNo())
                            .setInboundFlag(inb ? 1 : 0)
                            .setWareHouseCode(first.getWareHouseId());
                }
                return;
            }
            InOutResultReportParam p = objectMapper.readValue(body, InOutResultReportParam.class);
            if (p != null) {
                boolean inb = p.getInbound() == null || Boolean.TRUE.equals(p.getInbound());
                copy.setSourceOrderNo(p.getOrderNo())
                        .setInboundFlag(inb ? 1 : 0)
                        .setWareHouseCode(p.getWareHouseId());
            }
        } catch (Exception ignored) {
        }
    }
    private static boolean matchOrderType(String asnType, Boolean inbound) {
        if (StringUtils.isBlank(asnType)) {
            return true;
rsf-server/src/main/java/com/vincent/rsf/server/manager/service/impl/CloudWmsNotifyLogServiceImpl.java
@@ -1,34 +1,88 @@
package com.vincent.rsf.server.manager.service.impl;
import com.vincent.rsf.framework.common.R;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.vincent.rsf.server.api.controller.erp.params.InOutResultReportParam;
import com.vincent.rsf.server.common.service.RedisService;
import com.vincent.rsf.server.manager.entity.CloudWmsNotifyLog;
import com.vincent.rsf.server.manager.mapper.CloudWmsNotifyLogMapper;
import com.vincent.rsf.server.manager.service.CloudWmsNotifyLogService;
import com.vincent.rsf.server.system.constant.GlobalConfigCode;
import com.vincent.rsf.server.system.entity.Config;
import com.vincent.rsf.server.system.service.ConfigService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.util.Date;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
public class CloudWmsNotifyLogServiceImpl extends ServiceImpl<CloudWmsNotifyLogMapper, CloudWmsNotifyLog> implements CloudWmsNotifyLogService {
    /** 单条待办「正在上报」Redis 占位秒数(SET NX EX) */
    private static final int CLOUD_WMS_NOTIFY_SENDING_REDIS_TTL_SECONDS = 120;
    /** sending=1 但 Redis 无占位:update_time 早于此时长(分钟)则补偿清零 */
    private static final int STALE_SENDING_RECOVER_AFTER_MINUTES = 6;
    @Autowired
    private ConfigService configService;
    @Autowired
    private ObjectMapper objectMapper;
    @Autowired(required = false)
    private RedisService redisService;
    private static final String CLOUD_WMS_REDIS_FLAG = "cloudwms";
    private boolean useSendingRedis() {
        return redisService != null
                && Boolean.TRUE.equals(redisService.initialize)
                && CLOUD_WMS_NOTIFY_SENDING_REDIS_TTL_SECONDS > 0;
    }
    private static String sendingRedisSubKey(Long id) {
        return "sending." + id;
    }
    /** Redis 占位存在且未过期时视为正在上报 */
    private boolean isSendingHeldInRedis(Long id) {
        if (id == null || !useSendingRedis()) {
            return false;
        }
        try {
            String v = redisService.getValue(CLOUD_WMS_REDIS_FLAG, sendingRedisSubKey(id));
            return StringUtils.isNotBlank(v);
        } catch (Exception e) {
            return false;
        }
    }
    @Override
    public List<CloudWmsNotifyLog> listPending(int limit, int maxRetry) {
        LambdaQueryWrapper<CloudWmsNotifyLog> wrapper = new LambdaQueryWrapper<CloudWmsNotifyLog>()
                // 仅查询数据库配置状态:待通知 + 失败(可重试)
                .in(CloudWmsNotifyLog::getNotifyStatus, getNotifyStatusPending(), getNotifyStatusFail())
                .apply("(send_hold IS NULL OR send_hold = 0)")
                // 仅查询可重试数据:无限重试、未配置上限、或未达到上限
                .apply("(max_retry_count IS NULL OR max_retry_count = -1 OR retry_count < max_retry_count)")
                // 仅查询已到重试时间的数据,避免前 50 条未到间隔导致后续记录长期饥饿
                .apply("(last_notify_time IS NULL OR retry_interval_seconds IS NULL OR retry_interval_seconds <= 0 OR TIMESTAMPDIFF(SECOND, last_notify_time, NOW()) >= retry_interval_seconds)")
                //缺重试参数的不进入待发送列表
                .isNotNull(CloudWmsNotifyLog::getMaxRetryCount)
                .isNotNull(CloudWmsNotifyLog::getRetryIntervalSeconds)
                .orderByAsc(CloudWmsNotifyLog::getLastNotifyTime)
                .orderByAsc(CloudWmsNotifyLog::getId);
        if (maxRetry >= 0) {
@@ -39,6 +93,122 @@
        }
        Page<CloudWmsNotifyLog> page = new Page<>(1, Math.max(1, limit));
        return page(page, wrapper).getRecords();
    }
    @Override
    public boolean tryClaimSending(Long id) {
        if (id == null) {
            return false;
        }
        boolean useRedis = useSendingRedis();
        if (useRedis) {
            boolean got = redisService.trySetStringNxEx(CLOUD_WMS_REDIS_FLAG, sendingRedisSubKey(id), "1",
                    CLOUD_WMS_NOTIFY_SENDING_REDIS_TTL_SECONDS);
            if (!got) {
                CloudWmsNotifyLog cur = getById(id);
                if (cur != null && (cur.getSending() == null || cur.getSending() == 0)) {
                    redisService.deleteValue(CLOUD_WMS_REDIS_FLAG, sendingRedisSubKey(id));
                    got = redisService.trySetStringNxEx(CLOUD_WMS_REDIS_FLAG, sendingRedisSubKey(id), "1",
                            CLOUD_WMS_NOTIFY_SENDING_REDIS_TTL_SECONDS);
                }
                if (!got) {
                    return false;
                }
            }
        }
        LambdaUpdateWrapper<CloudWmsNotifyLog> u = new LambdaUpdateWrapper<>();
        u.eq(CloudWmsNotifyLog::getId, id)
                .set(CloudWmsNotifyLog::getSending, 1)
                .set(CloudWmsNotifyLog::getUpdateTime, new Date());
        if (!useRedis) {
            u.and(w -> w.isNull(CloudWmsNotifyLog::getSending).or().eq(CloudWmsNotifyLog::getSending, 0));
        }
        boolean ok = update(u);
        if (!ok && useRedis) {
            try {
                redisService.deleteValue(CLOUD_WMS_REDIS_FLAG, sendingRedisSubKey(id));
            } catch (Exception ignored) {
            }
        }
        return ok;
    }
    @Override
    public void clearSending(Long id) {
        if (id == null) {
            return;
        }
        if (useSendingRedis()) {
            try {
                redisService.deleteValue(CLOUD_WMS_REDIS_FLAG, sendingRedisSubKey(id));
            } catch (Exception e) {
                log.debug("云仓上报 clearSending Redis id={}", id, e);
            }
        }
        LambdaUpdateWrapper<CloudWmsNotifyLog> u = new LambdaUpdateWrapper<>();
        u.eq(CloudWmsNotifyLog::getId, id)
                .set(CloudWmsNotifyLog::getSending, 0)
                .set(CloudWmsNotifyLog::getUpdateTime, new Date());
        update(u);
    }
    @Override
    public void recoverStaleSendingWhenRedisMiss() {
        Date threshold = new Date(System.currentTimeMillis() - STALE_SENDING_RECOVER_AFTER_MINUTES * 60_000L);
        List<CloudWmsNotifyLog> rows = list(new LambdaQueryWrapper<CloudWmsNotifyLog>()
                .eq(CloudWmsNotifyLog::getSending, 1)
                .isNotNull(CloudWmsNotifyLog::getUpdateTime)
                .lt(CloudWmsNotifyLog::getUpdateTime, threshold)
                .last("LIMIT 500"));
        if (rows.isEmpty()) {
            return;
        }
        Date now = new Date();
        int cleared = 0;
        for (CloudWmsNotifyLog row : rows) {
            Long id = row.getId();
            if (id == null) {
                continue;
            }
            if (useSendingRedis() && isSendingHeldInRedis(id)) {
                continue;
            }
            LambdaUpdateWrapper<CloudWmsNotifyLog> u = new LambdaUpdateWrapper<>();
            u.eq(CloudWmsNotifyLog::getId, id)
                    .eq(CloudWmsNotifyLog::getSending, 1)
                    .lt(CloudWmsNotifyLog::getUpdateTime, threshold)
                    .set(CloudWmsNotifyLog::getSending, 0)
                    .set(CloudWmsNotifyLog::getUpdateTime, now);
            if (update(u)) {
                cleared++;
            }
        }
        if (cleared > 0) {
            log.info("云仓待办 sending 补偿清零 {} 条", cleared);
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public R manualFlushToNotifyByOrderCode(String orderCode, boolean inbound) {
        if (StringUtils.isBlank(orderCode)) {
            return R.error("单号不能为空");
        }
        int flag = inbound ? 1 : 0;
        Date now = new Date();
        LambdaUpdateWrapper<CloudWmsNotifyLog> u = new LambdaUpdateWrapper<>();
        u.eq(CloudWmsNotifyLog::getReportType, getReportTypeInOutResult())
                .eq(CloudWmsNotifyLog::getSourceOrderNo, orderCode.trim())
                .eq(CloudWmsNotifyLog::getInboundFlag, flag)
                .eq(CloudWmsNotifyLog::getSendHold, 1)
                .in(CloudWmsNotifyLog::getNotifyStatus, getNotifyStatusPending(), getNotifyStatusFail())
                .set(CloudWmsNotifyLog::getSendHold, 0)
                .set(CloudWmsNotifyLog::getUpdateTime, now);
        int n = getBaseMapper().update(null, u);
        if (n <= 0) {
            return R.error("当前无待放行的入出库待办(请确认 manual 模式、单号与入/出库类型一致)");
        }
        return R.ok("已放行云仓上报待办 " + n + " 条").add(n);
    }
    @Override
@@ -66,43 +236,93 @@
        return getConfigInt(GlobalConfigCode.CLOUD_WMS_NOTIFY_STATUS_FAIL, CloudWmsNotifyLog.NOTIFY_STATUS_FAIL);
    }
    private String getConfigString(String flag, String defaultVal) {
        Config c = configService.getOne(new LambdaQueryWrapper<Config>()
                .eq(Config::getFlag, flag)
                .orderByDesc(Config::getId)
                .last("LIMIT 1"));
        if (c != null && c.getVal() != null && !c.getVal().isEmpty()) {
            return c.getVal().trim();
    private String getConfigValTrimmed(String flag) {
        Config c = configService.getCachedOrLoad(flag);
        if (c == null || c.getVal() == null) {
            return null;
        }
        return defaultVal;
        String v = c.getVal().trim();
        return v.isEmpty() ? null : v;
    }
    /** 与实体常量搭配:仅当库/缓存无有效 val 时用常量 */
    private String getConfigString(String flag, String defaultVal) {
        String v = getConfigValTrimmed(flag);
        return v != null ? v : defaultVal;
    }
    private int getConfigInt(String flag, int defaultVal) {
        Integer v = getConfigInt(flag);
        return v != null ? v : defaultVal;
        Integer n = getConfigIntOrNull(flag);
        return n != null ? n : defaultVal;
    }
    private Integer getConfigIntOrNull(String flag) {
        String v = getConfigValTrimmed(flag);
        if (v == null) {
            return null;
        }
        try {
            return Integer.parseInt(v);
        } catch (NumberFormatException e) {
            return null;
        }
    }
    @Override
    public void fillFromConfig(CloudWmsNotifyLog log) {
        Integer maxRetry = getConfigInt(GlobalConfigCode.CLOUD_WMS_NOTIFY_MAX_RETRY);
        Integer interval = getConfigInt(GlobalConfigCode.CLOUD_WMS_NOTIFY_RETRY_INTERVAL_SECONDS);
        log.setNotifyStatus(getNotifyStatusPending());
        log.setMaxRetryCount(maxRetry);
        log.setRetryIntervalSeconds(interval);
        log.setMaxRetryCount(getConfigIntOrNull(GlobalConfigCode.CLOUD_WMS_NOTIFY_MAX_RETRY));
        log.setRetryIntervalSeconds(getConfigIntOrNull(GlobalConfigCode.CLOUD_WMS_NOTIFY_RETRY_INTERVAL_SECONDS));
    }
    /** 返回 null 表示未配置或解析失败 */
    private Integer getConfigInt(String flag) {
        try {
            Config c = configService.getOne(new LambdaQueryWrapper<Config>()
                    .eq(Config::getFlag, flag)
                    .orderByDesc(Config::getId)
                    .last("LIMIT 1"));
            if (c != null && c.getVal() != null && !c.getVal().isEmpty()) {
                return Integer.parseInt(c.getVal().trim());
    @Override
    public String inOutMergeKeyFromRequestBody(String requestBody) {
        return mergeKeyFromBody(requestBody);
            }
        } catch (Exception ignored) {
    @Override
    public List<InOutResultReportParam> parseInOutLinesFromRequestBody(String requestBody) throws IOException {
        return extractInOutLines(requestBody);
        }
    private String mergeKeyFromBody(String body) {
        if (StringUtils.isBlank(body)) {
        return null;
    }
        try {
            JsonNode root = objectMapper.readTree(body);
            if (root.has("lines") && root.get("lines").isArray() && root.get("lines").size() > 0) {
                return null;
            }
            JsonNode first = root;
            String orderNo = textNode(first, "orderNo");
            if (StringUtils.isBlank(orderNo)) {
                return null;
            }
            String wh = textNode(first, "wareHouseId");
            boolean inbound = !first.has("inbound") || first.get("inbound").isNull() || first.get("inbound").asBoolean();
            return orderNo + "\t" + inbound + "\t" + StringUtils.defaultString(wh);
        } catch (Exception e) {
            return null;
        }
    }
    private static String textNode(JsonNode n, String field) {
        if (n == null || !n.has(field) || n.get(field).isNull()) {
            return null;
        }
        return n.get(field).asText();
    }
    private List<InOutResultReportParam> extractInOutLines(String body) throws java.io.IOException {
        JsonNode root = objectMapper.readTree(body);
        if (root.has("lines") && root.get("lines").isArray()) {
            List<InOutResultReportParam> list = new ArrayList<>();
            for (JsonNode n : root.get("lines")) {
                list.add(objectMapper.treeToValue(n, InOutResultReportParam.class));
            }
            return list;
        }
        return Collections.singletonList(objectMapper.readValue(body, InOutResultReportParam.class));
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/manager/service/impl/CloudWmsOrderTaskRunningHelper.java
New file
@@ -0,0 +1,33 @@
package com.vincent.rsf.server.manager.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.vincent.rsf.server.manager.entity.Task;
import com.vincent.rsf.server.manager.enums.TaskStsType;
import com.vincent.rsf.server.manager.mapper.TaskMapper;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
 * 任务明细 plat_order_code 对应云仓通知单号时,是否仍有执行中任务
 */
@Component
public class CloudWmsOrderTaskRunningHelper {
    @Autowired
    private TaskMapper taskMapper;
    public boolean hasRunningTasksForPlatOrder(String platOrderCode) {
        if (StringUtils.isBlank(platOrderCode)) {
            return false;
        }
        QueryWrapper<Task> qw = new QueryWrapper<>();
        qw.eq("deleted", 0);
        qw.apply("EXISTS (SELECT 1 FROM man_task_item ti WHERE ti.task_id = man_task.id AND ti.deleted = 0 AND ti.plat_order_code = {0})", platOrderCode);
        qw.and(w -> w
                .nested(n -> n.lt("task_type", 100).lt("task_status", TaskStsType.COMPLETE_IN.id))
                .or(n -> n.ge("task_type", 101).lt("task_status", TaskStsType.COMPLETE_OUT.id)));
        Integer cnt = taskMapper.selectCount(qw);
        return cnt != null && cnt > 0;
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/manager/service/impl/TaskServiceImpl.java
@@ -27,6 +27,7 @@
import com.vincent.rsf.framework.common.R;
import com.vincent.rsf.framework.exception.CoolException;
import com.vincent.rsf.server.api.utils.LocUtils;
import com.vincent.rsf.server.manager.constant.CloudWmsInoutReportMode;
import com.vincent.rsf.server.manager.controller.params.GenerateTaskParams;
import com.vincent.rsf.server.manager.entity.CloudWmsNotifyLog;
import com.vincent.rsf.server.manager.entity.*;
@@ -2887,8 +2888,45 @@
                log.info("入/出库结果上报待办跳过:无云仓来源单据,taskId={}", task.getId());
                return;
            }
            Set<Long> orderIdSet = taskItems.stream()
                    .filter(Objects::nonNull)
                    .map(TaskItem::getOrderId)
                    .filter(Objects::nonNull)
                    .collect(Collectors.toSet());
            Map<Long, WkOrder> orderById = new HashMap<>();
            if (!orderIdSet.isEmpty()) {
                for (WkOrder o : asnOrderService.listByIds(orderIdSet)) {
                    if (o != null && o.getId() != null) {
                        orderById.put(o.getId(), o);
                    }
                }
            }
            Set<String> orderCodeSet = new HashSet<>();
            for (TaskItem ti : taskItems) {
                if (ti == null) {
                    continue;
                }
                String on = isInbound && ti.getSource() != null
                        ? sourceToOrderNo.get(ti.getSource())
                        : (ti.getPlatOrderCode() != null ? ti.getPlatOrderCode() : ti.getPlatWorkCode());
                if (on == null && isInbound) {
                    on = ti.getPlatOrderCode() != null ? ti.getPlatOrderCode() : ti.getPlatWorkCode();
                }
                if (StringUtils.isNotBlank(on)) {
                    orderCodeSet.add(on);
                }
            }
            Map<String, WkOrder> orderByCode = new HashMap<>();
            if (!orderCodeSet.isEmpty()) {
                for (WkOrder o : asnOrderService.list(new LambdaQueryWrapper<WkOrder>().in(WkOrder::getCode, orderCodeSet))) {
                    if (o != null && StringUtils.isNotBlank(o.getCode())) {
                        orderByCode.put(o.getCode(), o);
                    }
                }
            }
            ObjectMapper om = new ObjectMapper();
            Date now = new Date();
            Map<String, List<InOutResultReportParam>> byOrder = new LinkedHashMap<>();
            for (TaskItem item : taskItems) {
                if (item == null) {
                    continue;
@@ -2907,6 +2945,13 @@
                if (orderNo == null || item.getMatnrCode() == null) {
                    continue;
                }
                WkOrder asnOrder = null;
                if (item.getOrderId() != null) {
                    asnOrder = orderById.get(item.getOrderId());
                }
                if (asnOrder == null) {
                    asnOrder = orderByCode.get(orderNo);
                }
                InOutResultReportParam param = new InOutResultReportParam()
                        .setOrderNo(orderNo)
                        .setPlanNo(item.getPlatWorkCode())
@@ -2914,26 +2959,42 @@
                        .setUnitNo(item.getUnit())
                        .setLineId(item.getPlatItemId())
                        .setWareHouseId(wareHouseId)
                        .setDocWarehouseNo(asnOrder != null ? asnOrder.getDocTaskWarehouseNo() : null)
                        .setOrgNo(asnOrder != null ? asnOrder.getDocOrgNo() : null)
                        .setInWarehouseNo(isInbound && asnOrder != null ? asnOrder.getDocInWarehouseNo() : null)
                        .setOutWarehouseNo(!isInbound && asnOrder != null ? asnOrder.getDocOutWarehouseNo() : null)
                        .setLocId(locId)
                        .setMatNr(item.getMatnrCode())
                        .setQty(item.getAnfme() != null ? String.valueOf(item.getAnfme()) : "0")
                        .setBatch(item.getBatch())
                        .setInbound(isInbound)
                        .setBarcode(task.getBarcode());
                byOrder.computeIfAbsent(orderNo, k -> new ArrayList<>()).add(param);
            }
            String mode = resolveCloudWmsInoutReportMode();
            boolean sendHold = CloudWmsInoutReportMode.MANUAL.equals(mode) || CloudWmsInoutReportMode.WAIT_ORDER.equals(mode);
            for (Map.Entry<String, List<InOutResultReportParam>> e : byOrder.entrySet()) {
                String orderNo = e.getKey();
                for (InOutResultReportParam param : e.getValue()) {
                try {
                    String requestBody = om.writeValueAsString(param);
                    CloudWmsNotifyLog notifyLog = new CloudWmsNotifyLog()
                            .setReportType(cloudWmsNotifyLogService.getReportTypeInOutResult())
                            .setRequestBody(requestBody)
                            .setNotifyStatus(cloudWmsNotifyLogService.getNotifyStatusPending())
                            .setRetryCount(0)
                            .setBizRef("taskId=" + task.getId() + ",orderNo=" + orderNo)
                            .setCreateTime(now)
                            .setUpdateTime(now);
                                .setUpdateTime(now)
                                .setSourceOrderNo(orderNo)
                                .setInboundFlag(isInbound ? 1 : 0)
                                .setWareHouseCode(wareHouseId)
                                .setSendHold(sendHold ? 1 : 0)
                                .setSending(0);
                    cloudWmsNotifyLogService.fillFromConfig(notifyLog);
                    cloudWmsNotifyLogService.save(notifyLog);
                } catch (JsonProcessingException e) {
                    log.warn("入/出库结果上报待办落库失败(不影响主流程),taskId={},orderNo={}:{}", task.getId(), orderNo, e.getMessage());
                    } catch (JsonProcessingException ex) {
                        log.warn("入/出库结果上报待办落库失败(不影响主流程),taskId={},orderNo={}:{}", task.getId(), orderNo, ex.getMessage());
                    }
                }
            }
        } catch (Exception e) {
@@ -2948,4 +3009,16 @@
        return StringUtils.isNotBlank(item.getPlatOrderCode())
                || StringUtils.isNotBlank(item.getPlatWorkCode());
    }
    /** sys_config CLOUD_WMS_INOUT_REPORT_MODE:immediate / wait_order / manual */
    private String resolveCloudWmsInoutReportMode() {
        try {
            Config cfg = configService.getCachedOrLoad(GlobalConfigCode.CLOUD_WMS_INOUT_REPORT_MODE);
            if (cfg != null && StringUtils.isNotBlank(cfg.getVal())) {
                return cfg.getVal().trim().toLowerCase();
            }
        } catch (Exception ignored) {
        }
        return CloudWmsInoutReportMode.IMMEDIATE;
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/system/config/ConfigCacheProperties.java
@@ -5,13 +5,12 @@
import org.springframework.stereotype.Component;
/**
 * sys_config 经 Redis 缓存时的过期时间(非永久,到期后读库刷新)。
 * sys_config 写入 Redis 时的过期秒数,由 {@code config.cache.redis-ttl-seconds} 配置。
 */
@Data
@Component
@ConfigurationProperties(prefix = "config.cache")
public class ConfigCacheProperties {
    /** Redis 中 SYS_CONFIG 条目的过期秒数,须 >=1(默认 4 小时) */
    private int redisTtlSeconds = 14400;
    private int redisTtlSeconds;
}
rsf-server/src/main/java/com/vincent/rsf/server/system/constant/GlobalConfigCode.java
@@ -47,6 +47,18 @@
    /** 云仓通知入库体最大长度(last_request_body / last_response_body,默认 2000) */
    public final static String CLOUD_WMS_NOTIFY_STORE_BODY_MAX_CHARS = "CLOUD_WMS_NOTIFY_STORE_BODY_MAX_CHARS";
    /**
     * 云仓入出库回馈模式(sys_config.val,type=3):immediate 立即待办;
     * wait_order 实绩落待办且暂缓发送,空闲/整单结束后放行;manual 暂缓发送,由通知单按钮放行。
     */
    public final static String CLOUD_WMS_INOUT_REPORT_MODE = "CLOUD_WMS_INOUT_REPORT_MODE";
    /** wait_order:超过该秒数无新实绩更新则放行暂缓的待办(可与整单结束择先触发) */
    public final static String CLOUD_WMS_INOUT_AGG_IDLE_SECONDS = "CLOUD_WMS_INOUT_AGG_IDLE_SECONDS";
    /** 同订单已无执行中任务后,再等待该秒数再整批上报,避免连续完成任务时拆单 */
    public final static String CLOUD_WMS_INOUT_AGG_COMPLETE_DEBOUNCE_SECONDS = "CLOUD_WMS_INOUT_AGG_COMPLETE_DEBOUNCE_SECONDS";
    /** 指定物料自动全板出库:物料编码(val=物料编码时启用按物料自动生成全板出库单) */
    public final static String AUTO_FULL_OUT_MATNR_CODE = "AUTO_FULL_OUT_MATNR_CODE";
    /** 是否启用:有库存时自动生成全板出库单 */
rsf-server/src/main/java/com/vincent/rsf/server/system/service/ConfigService.java
@@ -7,8 +7,8 @@
public interface ConfigService extends IService<Config> {
    /**
     * 优先 JVM 内全局缓存(启动预载 + 后台增改删时维护),未命中或缓存已失效再查库并回填。
     * 若启用 Redis:先读带 TTL 的副本,过期或缺失则读库并以 setex 回写;不使用永久 key。
     * Redis 可用时:先读 SYS_CONFIG 带 TTL 的条目;未命中或无效则读库,命中后尝试 setex 回写(写失败仍返回库里的值)。
     * 无 Redis 时:用进程内 CONFIG_CACHE,未命中再读库。
     */
    Config getCachedOrLoad(String flag);
rsf-server/src/main/java/com/vincent/rsf/server/system/service/impl/ConfigServiceImpl.java
@@ -79,8 +79,15 @@
    }
    private void tryRedisSetexConfig(String flag, Config loaded) {
        if (!redisReady() || loaded == null) {
            return;
        }
        int ttl = configCacheProperties.getRedisTtlSeconds();
        if (ttl <= 0) {
            log.warn("sys_config Redis setex 已跳过:config.cache.redis-ttl-seconds 须为正数,flag={}", flag);
            return;
        }
        try {
            int ttl = Math.max(1, configCacheProperties.getRedisTtlSeconds());
            redisService.set(REDIS_FLAG_SYS_CONFIG, flag, loaded, ttl);
        } catch (Exception e) {
            log.warn("sys_config Redis setex flag={}", flag, e);
rsf-server/src/main/resources/application-dev.yml
@@ -106,13 +106,6 @@
    # Feign 调用云仓时的根地址。本机模拟:http://127.0.0.1:8086/rsf-server(CloudWmsMockController:ICusStockService stockIn/Out/TransferCompleted)
    # base-url: http://127.0.0.1:8086/rsf-server
    base-url: http://192.168.10.108:8180
    # 鼎捷 DAP ilcwmsplus 完成反馈(9.1/9.2 组包用)
    dap:
      org-no: ""
      doc-type-in: ""
      doc-type-out: ""
      doc-type-adj: ""
      unit-no: PCS
    #接口明细(质检等;Feign 已固定 ICusStockService stockInCompleted、stockOutCompleted;调拨 changeType=3 为 stockTransferCompleted)
    api:
      notify-inspect: /report/inspect
rsf-server/src/main/resources/application-prod.yml
@@ -9,6 +9,11 @@
    openfeign:
      circuitbreaker:
        enabled: true   # Feign 调用失败时走 Fallback,在 Feign 内统一返回错误响应
      client:
        config:
          cloudWmsErp:
            connectTimeout: 5000
            readTimeout: 10000
  mvc:
    static-path-pattern: /**
    path match:
@@ -101,9 +106,6 @@
    # Feign 调用云仓时的根地址。本机模拟:http://127.0.0.1:8086/rsf-server(CloudWmsMockController:ICusStockService stockIn/Out/TransferCompleted)
    # base-url: http://127.0.0.1:8086/rsf-server
    base-url: http://192.168.10.108:8180
    # 鼎捷 DAP ilcwmsplus 完成反馈(9.1/9.2 组包用)
    dap:
      org-no: "1"
    #接口明细(质检等;下方 stock-in/out-completed-path 与 CloudWmsErpFeignClient 一致;调拨 changeType=3 为 stockTransferCompleted)
    api:
      notify-inspect: /report/inspect
rsf-server/src/main/resources/application.yml
@@ -7,8 +7,8 @@
  system-version: @pom.version@
  system-mode: OFFLINE
  cache:
    # sys_config 写入 Redis 的过期秒数(setex,不用永久 key);到期后下次读取从库刷新(4 小时)
    redis-ttl-seconds: 14400
    # sys_config 写入 Redis 的过期秒数(setex);到期后下次 getCachedOrLoad 从库刷新再回写
    redis-ttl-seconds: 3600
  token-key: KUHSMcYQ4lePt3r6bckz0P13cBJyoonYqInThvQlUnbsFCIcCcZZAbWZ6UNFztYNYPhGdy6eyb8WdIz8FU2Cz396TyTJk3NI2rtXMHBOehRb4WWJ4MdYVVg2oWPyqRQ2
  super-username: root
  code-length: 6