feat: add outbound WCS retry dispatch

This commit is contained in:
zgw
2026-07-16 10:02:45 +08:00
parent aa6728e52b
commit 9158a5fa54
18 changed files with 845 additions and 91 deletions

View File

@@ -86,13 +86,27 @@ public class WcsClient extends BaseExternalClient {
}
try {
String body = objectMapper.writeValueAsString(job);
// 正式WCS作业对象仍使用统一接收入口;地址按出库作业所属仓库解析。
return post(jobUrl("sendPutawayTask", job), body, "WCS-下发出库任务", this::parseWcsAck, MsgTypeEnum.JSON);
return post(jobUrl("sendRetrieveTask", job), body, "WCS-下发出库任务", this::parseWcsAck, MsgTypeEnum.JSON);
} catch (Exception e) {
return ApiResponse.fail("SERIALIZE_FAIL", "出库作业JSON报文生成失败: " + e.getMessage());
}
}
/** 兼容统一出库命名,实际使用普通出库接口。 */
public ApiResponse<String> sendOutboundTask(MydoJob job) {
return sendRetrieveTask(job);
}
/** 下发指定货位出库任务。 */
public ApiResponse<String> sendLocationOutboundTask(MydoJob job) {
return sendJsonJob(job, "sendLocationOutboundTask", "WCS-下发指定货位出库任务", "MOCK_WCS_LOCATION_OUT_OK");
}
/** 下发密集库移库任务。 */
public ApiResponse<String> sendRelocationTask(MydoJob job) {
return sendJsonJob(job, "sendRelocationTask", "WCS-下发移库任务", "MOCK_WCS_RELOCATION_OK");
}
// ── 接口3:查询设备状态 ───────────────────────────────────
public ApiResponse<List<Map<String, String>>> queryDeviceStatus(String deviceType) {
@@ -161,6 +175,21 @@ public class WcsClient extends BaseExternalClient {
return config().getApiUrl(apiKey, job == null ? null : job.getWarehouseId());
}
private ApiResponse<String> sendJsonJob(MydoJob job, String apiKey, String description, String mockResult) {
if (job == null) {
return ApiResponse.fail("PARAM_ERROR", "作业不能为空");
}
if (mockEnabled()) {
return ApiResponse.ok(mockResult);
}
try {
String body = objectMapper.writeValueAsString(job);
return post(jobUrl(apiKey, job), body, description, this::parseWcsAck, MsgTypeEnum.JSON);
} catch (Exception e) {
return ApiResponse.fail("SERIALIZE_FAIL", "作业JSON报文生成失败: " + e.getMessage());
}
}
private String parseOkNg(String rawBody) {
String[] parts = rawBody.trim().split("\\|", 2);
if ("OK".equals(parts[0])) {

View File

@@ -23,6 +23,8 @@ import com.ruoyi.in.jobdetail.mapper.MydoJobDetailMapper;
import com.ruoyi.in.stockin.mapper.InStockinMapper;
import com.ruoyi.out.stockout.mapper.OutStockoutMapper;
import com.ruoyi.out.stockoutdetail.mapper.OutStockoutDetailMapper;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
import com.ruoyi.out.dispatch.service.IOutboundDispatchService;
import com.ruoyi.pd.diff.mapper.PdDifferenceRecordMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
@@ -78,6 +80,9 @@ public class MydoJobServiceImpl implements IMydoJobService
@Autowired
private PdDifferenceRecordMapper pdDifferenceRecordMapper;
@Autowired
private IOutboundDispatchService outboundDispatchService;
/**
* 查询作业信息
*
@@ -309,38 +314,7 @@ public class MydoJobServiceImpl implements IMydoJobService
@Override
public AjaxResult pushOutJobToWcs(String jobId)
{
if (StringUtils.isEmpty(jobId)) {
return AjaxResult.error("作业ID不能为空");
}
MydoJob job = mydoJobMapper.selectMydoJobByJobId(jobId);
if (job == null) {
return AjaxResult.error("出库作业不存在");
}
if (job.getInOrOut() == null || job.getInOrOut() < 20) {
return AjaxResult.error("请选择出库作业");
}
if (!"1".equals(job.getStatus())) {
if ("2".equals(job.getStatus()) || "3".equals(job.getStatus())) {
return AjaxResult.success("出库作业已下发,无需重复推送");
}
return AjaxResult.error("当前作业状态不允许下发WCS");
}
ApiResponse<String> response = wcsClient.sendRetrieveTask(job);
if (response == null || !response.isSuccess()) {
String errorMessage = response == null ? "WCS接口无返回" : response.getMessage();
return AjaxResult.error("WCS出库作业下发失败:" + errorMessage);
}
MydoJob updateJob = new MydoJob();
updateJob.setJobId(job.getJobId());
updateJob.setStatus("3");
updateJob.setExecutionPhase("1");
updateJob.setStartTime(DateUtils.getTime());
if (mydoJobMapper.updateMydoJob(updateJob) <= 0) {
return AjaxResult.error("出库作业状态更新失败");
}
return AjaxResult.success("WCS出库作业下发成功:" + Convert.toStr(response.getData()));
return outboundDispatchService.dispatch(jobId, DispatchTrigger.INITIAL);
}
/**

View File

@@ -0,0 +1,36 @@
package com.ruoyi.out.dense.domain;
/**
* 密集库作业下发资格。
*/
public class DispatchEligibility
{
private final boolean eligible;
private final String reason;
private DispatchEligibility(boolean eligible, String reason)
{
this.eligible = eligible;
this.reason = reason;
}
public static DispatchEligibility allowed()
{
return new DispatchEligibility(true, null);
}
public static DispatchEligibility blocked(String reason)
{
return new DispatchEligibility(false, reason);
}
public boolean isEligible()
{
return eligible;
}
public String getReason()
{
return reason;
}
}

View File

@@ -0,0 +1,11 @@
package com.ruoyi.out.dense.mapper;
import org.apache.ibatis.annotations.Param;
/**
* 密集库通道查询 Mapper。
*/
public interface DenseLaneMapper
{
Integer isCurrentOutermost(@Param("cargoSpaceId") String cargoSpaceId);
}

View File

@@ -0,0 +1,12 @@
package com.ruoyi.out.dense.service;
import com.ruoyi.in.job.domain.MydoJob;
import com.ruoyi.out.dense.domain.DispatchEligibility;
/**
* 密集库通道服务。
*/
public interface IDenseLaneService
{
DispatchEligibility checkDispatchEligibility(MydoJob job);
}

View File

@@ -0,0 +1,41 @@
package com.ruoyi.out.dense.service.impl;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.in.job.domain.MydoJob;
import com.ruoyi.out.dense.domain.DispatchEligibility;
import com.ruoyi.out.dense.mapper.DenseLaneMapper;
import com.ruoyi.out.dense.service.IDenseLaneService;
import org.springframework.stereotype.Service;
/**
* 密集库通道服务实现。
*/
@Service
public class DenseLaneServiceImpl implements IDenseLaneService
{
private final DenseLaneMapper denseLaneMapper;
public DenseLaneServiceImpl(DenseLaneMapper denseLaneMapper)
{
this.denseLaneMapper = denseLaneMapper;
}
@Override
public DispatchEligibility checkDispatchEligibility(MydoJob job)
{
if (job == null) {
return DispatchEligibility.blocked("出库作业不存在");
}
if (!"03".equals(job.getWarehouseId())) {
return DispatchEligibility.allowed();
}
if (!StringUtils.hasText(job.getCargoSpaceId())) {
return DispatchEligibility.blocked("03仓出库作业未分配源货位");
}
Integer outermost = denseLaneMapper.isCurrentOutermost(job.getCargoSpaceId());
if (Integer.valueOf(1).equals(outermost)) {
return DispatchEligibility.allowed();
}
return DispatchEligibility.blocked("当前货位不是密集库通道最外侧货位");
}
}

View File

@@ -0,0 +1,11 @@
package com.ruoyi.out.dispatch.domain;
/**
* WCS 下发触发方式。
*/
public enum DispatchTrigger
{
INITIAL,
AUTO_RETRY,
MANUAL
}

View File

@@ -0,0 +1,14 @@
package com.ruoyi.out.dispatch.service;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
/**
* 出库作业 WCS 下发服务。
*/
public interface IOutboundDispatchService
{
AjaxResult dispatch(String jobId, DispatchTrigger trigger);
int retryFailedJobs();
}

View File

@@ -0,0 +1,239 @@
package com.ruoyi.out.dispatch.service.impl;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.ruoyi.api.client.WcsClient;
import com.ruoyi.api.domain.ApiResponse;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.text.Convert;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.in.job.domain.MydoJob;
import com.ruoyi.in.job.mapper.MydoJobMapper;
import com.ruoyi.out.dense.domain.DispatchEligibility;
import com.ruoyi.out.dense.service.IDenseLaneService;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
import com.ruoyi.out.dispatch.domain.WcsJobDispatch;
import com.ruoyi.out.dispatch.mapper.WcsJobDispatchMapper;
import com.ruoyi.out.dispatch.service.IOutboundDispatchService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/**
* 出库作业 WCS 下发服务实现。
*/
@Service
public class OutboundDispatchServiceImpl implements IOutboundDispatchService
{
private static final Logger log = LoggerFactory.getLogger(OutboundDispatchServiceImpl.class);
private static final int MAX_AUTO_RETRY_COUNT = 5;
private static final int RETRY_BATCH_SIZE = 100;
private static final long RETRY_DELAY_MILLIS = 60_000L;
private final MydoJobMapper jobMapper;
private final WcsJobDispatchMapper dispatchMapper;
private final IDenseLaneService denseLaneService;
private final WcsClient wcsClient;
public OutboundDispatchServiceImpl(MydoJobMapper jobMapper,
WcsJobDispatchMapper dispatchMapper,
IDenseLaneService denseLaneService,
WcsClient wcsClient)
{
this.jobMapper = jobMapper;
this.dispatchMapper = dispatchMapper;
this.denseLaneService = denseLaneService;
this.wcsClient = wcsClient;
}
@Override
public AjaxResult dispatch(String jobId, DispatchTrigger trigger)
{
if (!StringUtils.hasText(jobId)) {
return AjaxResult.error("作业ID不能为空");
}
if (trigger == null) {
return AjaxResult.error("WCS下发触发方式不能为空");
}
MydoJob job = jobMapper.selectMydoJobByJobId(jobId.trim());
AjaxResult validation = validateJob(job);
if (validation != null) {
return validation;
}
dispatchMapper.insertIfAbsent(job.getJobId());
WcsJobDispatch dispatch = dispatchMapper.selectByJobId(job.getJobId());
if (dispatch == null) {
return AjaxResult.error("WCS下发记录创建失败");
}
if (isActiveSending(dispatch)) {
return AjaxResult.success("作业正在下发WCS,无需重复处理");
}
if (trigger == DispatchTrigger.AUTO_RETRY
&& retryCount(dispatch) >= MAX_AUTO_RETRY_COUNT) {
if (!WcsJobDispatch.EXHAUSTED.equals(dispatch.getDispatchStatus())) {
Map<String, Object> exhaustedParams = markParams(
job.getJobId(), dispatch, "WCS自动重试次数已达上限", new Date());
exhaustedParams.put("autoRetryCount", retryCount(dispatch));
dispatchMapper.markExhausted(exhaustedParams);
}
return AjaxResult.success("WCS自动重试次数已达上限,等待人工下发");
}
DispatchEligibility eligibility = denseLaneService.checkDispatchEligibility(job);
if (eligibility == null || !eligibility.isEligible()) {
String reason = eligibility == null ? "密集库下发资格校验无返回" : eligibility.getReason();
dispatchMapper.markBlocked(markParams(job.getJobId(), dispatch, reason, new Date()));
return AjaxResult.success("作业暂未下发WCS:" + reason);
}
DispatchTrigger attemptTrigger = resolveAttemptTrigger(trigger, dispatch);
Date attemptTime = new Date();
Map<String, Object> claimParams = new HashMap<>();
claimParams.put("jobId", job.getJobId());
claimParams.put("trigger", attemptTrigger.name());
claimParams.put("attemptTime", attemptTime);
claimParams.put("versionNo", version(dispatch));
if (dispatchMapper.claimForSend(claimParams) != 1) {
return AjaxResult.success("作业正在下发或状态已变化,无需重复处理");
}
int claimedVersion = version(dispatch) + 1;
ApiResponse<String> response;
try {
response = sendByType(job);
} catch (Exception e) {
log.error("WCS出库作业下发异常,jobId={}", job.getJobId(), e);
return handleFailure(job, dispatch, claimedVersion, attemptTrigger, e.getMessage());
}
if (response == null || !response.isSuccess()) {
String errorMessage = response == null ? "WCS接口无返回" : response.getMessage();
return handleFailure(job, dispatch, claimedVersion, attemptTrigger, errorMessage);
}
MydoJob updateJob = new MydoJob();
updateJob.setJobId(job.getJobId());
updateJob.setStatus("3");
updateJob.setExecutionPhase("1");
updateJob.setStartTime(DateUtils.getTime());
if (jobMapper.updateMydoJob(updateJob) != 1) {
return handleFailure(job, dispatch, claimedVersion, attemptTrigger, "WCS已接收,但出库作业状态更新失败");
}
Map<String, Object> successParams = new HashMap<>();
successParams.put("jobId", job.getJobId());
successParams.put("successTime", new Date());
successParams.put("versionNo", claimedVersion);
dispatchMapper.markSuccess(successParams);
return AjaxResult.success("WCS出库作业下发成功:" + Convert.toStr(response.getData()));
}
@Override
public int retryFailedJobs()
{
List<WcsJobDispatch> candidates = dispatchMapper.selectRetryCandidates(new Date(), RETRY_BATCH_SIZE);
int processed = 0;
for (WcsJobDispatch candidate : candidates) {
try {
dispatch(candidate.getJobId(), DispatchTrigger.AUTO_RETRY);
} catch (Exception e) {
log.error("WCS出库作业自动补偿异常,jobId={}", candidate.getJobId(), e);
}
processed++;
}
return processed;
}
private AjaxResult validateJob(MydoJob job)
{
if (job == null) {
return AjaxResult.error("出库作业不存在");
}
if (job.getInOrOut() == null || job.getInOrOut() < 20) {
return AjaxResult.error("请选择出库作业");
}
if (!"1".equals(job.getStatus())) {
if ("2".equals(job.getStatus()) || "3".equals(job.getStatus())) {
return AjaxResult.success("出库作业已下发,无需重复推送");
}
return AjaxResult.error("当前作业状态不允许下发WCS");
}
return null;
}
private ApiResponse<String> sendByType(MydoJob job)
{
if (Long.valueOf(20L).equals(job.getInOrOut())) {
return wcsClient.sendLocationOutboundTask(job);
}
if (Long.valueOf(23L).equals(job.getInOrOut())) {
return wcsClient.sendRelocationTask(job);
}
return wcsClient.sendRetrieveTask(job);
}
private AjaxResult handleFailure(MydoJob job, WcsJobDispatch dispatch, int claimedVersion,
DispatchTrigger trigger, String errorMessage)
{
int autoRetryCount = retryCount(dispatch)
+ (trigger == DispatchTrigger.AUTO_RETRY ? 1 : 0);
String reason = StringUtils.hasText(errorMessage) ? errorMessage : "WCS下发失败";
Map<String, Object> params = markParams(job.getJobId(), dispatch, reason, new Date());
params.put("autoRetryCount", autoRetryCount);
params.put("versionNo", claimedVersion);
if (trigger == DispatchTrigger.MANUAL || autoRetryCount >= MAX_AUTO_RETRY_COUNT) {
dispatchMapper.markExhausted(params);
if (trigger == DispatchTrigger.MANUAL) {
return AjaxResult.error("WCS手动下发失败:" + reason);
}
return AjaxResult.success("WCS自动重试已达5次,等待人工下发:" + reason);
}
params.put("nextRetryTime", new Date(System.currentTimeMillis() + RETRY_DELAY_MILLIS));
dispatchMapper.markRetryWait(params);
return AjaxResult.success("出库作业已生成,WCS下发失败,已进入补偿队列:" + reason);
}
private Map<String, Object> markParams(String jobId, WcsJobDispatch dispatch,
String lastError, Date updateTime)
{
Map<String, Object> params = new HashMap<>();
params.put("jobId", jobId);
params.put("lastError", lastError);
params.put("updateTime", updateTime);
params.put("versionNo", version(dispatch));
params.put("autoRetryCount", retryCount(dispatch));
params.put("nextRetryTime", new Date(updateTime.getTime() + RETRY_DELAY_MILLIS));
return params;
}
private int retryCount(WcsJobDispatch dispatch)
{
return dispatch.getAutoRetryCount() == null ? 0 : dispatch.getAutoRetryCount();
}
private DispatchTrigger resolveAttemptTrigger(DispatchTrigger requestedTrigger, WcsJobDispatch dispatch)
{
if (requestedTrigger == DispatchTrigger.AUTO_RETRY && dispatch.getLastAttemptTime() == null) {
return DispatchTrigger.INITIAL;
}
return requestedTrigger;
}
private boolean isActiveSending(WcsJobDispatch dispatch)
{
return WcsJobDispatch.SENDING.equals(dispatch.getDispatchStatus())
&& dispatch.getLastAttemptTime() != null
&& dispatch.getLastAttemptTime().getTime() > System.currentTimeMillis() - 5 * 60_000L;
}
private int version(WcsJobDispatch dispatch)
{
return dispatch.getVersionNo() == null ? 0 : dispatch.getVersionNo();
}
}

View File

@@ -0,0 +1,23 @@
package com.ruoyi.out.dispatch.task;
import com.ruoyi.out.dispatch.service.IOutboundDispatchService;
import org.springframework.stereotype.Component;
/**
* WCS 出库作业失败补偿任务。
*/
@Component("wcsOutboundRetryTask")
public class WcsOutboundRetryTask
{
private final IOutboundDispatchService outboundDispatchService;
public WcsOutboundRetryTask(IOutboundDispatchService outboundDispatchService)
{
this.outboundDispatchService = outboundDispatchService;
}
public int retryFailedJobs()
{
return outboundDispatchService.retryFailedJobs();
}
}

View File

@@ -7,6 +7,8 @@ import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.in.job.domain.MydoJob;
import com.ruoyi.in.job.service.IMydoJobService;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
import com.ruoyi.out.dispatch.service.IOutboundDispatchService;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired;
@@ -37,6 +39,9 @@ public class OutMydoJobController extends BaseController
@Autowired
private IMydoJobService mydoJobService;
@Autowired
private IOutboundDispatchService outboundDispatchService;
/**
* 查询作业信息列表
*/
@@ -147,11 +152,11 @@ public class OutMydoJobController extends BaseController
/**
* 推送出库作业到 WCS
*/
@PreAuthorize("@ss.hasPermi('out:job:edit')")
@PreAuthorize("@ss.hasPermi('out:job:pushWcs')")
@Log(title = "作业信息-WCS推送", businessType = BusinessType.UPDATE)
@PutMapping("/pushWcs/{jobId}")
public AjaxResult pushWcs(@PathVariable("jobId") String jobId)
{
return mydoJobService.pushOutJobToWcs(jobId);
return outboundDispatchService.dispatch(jobId, DispatchTrigger.MANUAL);
}
}

View File

@@ -176,8 +176,10 @@ external:
'02': ${WCS_02_BASE_URL:${WCS_DEFAULT_BASE_URL:http://172.16.4.63:8084}}
'03': ${WCS_03_BASE_URL:${WCS_DEFAULT_BASE_URL:http://172.16.4.63:8084}}
apis:
sendPutawayTask: /wcs/api/receiveWmsJob
sendRetrieveTask: /wcs/api/receiveWmsJob
sendPutawayTask: ${WCS_PUTAWAY_PATH:/wcs/api/receiveWmsJob}
sendRetrieveTask: ${WCS_OUTBOUND_PATH:/wcs/api/receiveWmsOutJob}
sendLocationOutboundTask: ${WCS_LOCATION_OUTBOUND_PATH:/wcs/api/receiveWmsLocationOutJob}
sendRelocationTask: ${WCS_RELOCATION_PATH:/wcs/api/receiveWmsMoveJob}
queryDeviceStatus: /device/status
sendCraneMove: /crane/move
cancelTask: /task/cancel

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.out.dense.mapper.DenseLaneMapper">
<select id="isCurrentOutermost" resultType="java.lang.Integer">
select case
when source_space.warehouse_id != '03'
or source_space.group_coding is null
or ltrim(rtrim(source_space.group_coding)) = '' then 1
when not exists
(
select 1
from bas_cargo_space occupied
cross apply
(
select min(lane_space.the_column) as min_column,
max(lane_space.the_column) as max_column
from bas_cargo_space lane_space
where lane_space.warehouse_id = source_space.warehouse_id
and lane_space.group_coding = source_space.group_coding
and lane_space.the_floor = source_space.the_floor
) lane_boundary
where occupied.warehouse_id = source_space.warehouse_id
and occupied.group_coding = source_space.group_coding
and occupied.the_floor = source_space.the_floor
and occupied.cargo_space_id != source_space.cargo_space_id
and occupied.status in ('2', '3', '4')
and occupied.tray_code is not null
and ltrim(rtrim(occupied.tray_code)) != ''
and
(
(isnull(area_config.direction_type, '1') = '1'
and occupied.the_column &gt; source_space.the_column)
or (area_config.direction_type = '2'
and occupied.the_column &lt; source_space.the_column)
or (area_config.direction_type = '3'
and case
when occupied.the_column - lane_boundary.min_column
&lt;= lane_boundary.max_column - occupied.the_column
then occupied.the_column - lane_boundary.min_column
else lane_boundary.max_column - occupied.the_column
end
&lt; case
when source_space.the_column - lane_boundary.min_column
&lt;= lane_boundary.max_column - source_space.the_column
then source_space.the_column - lane_boundary.min_column
else lane_boundary.max_column - source_space.the_column
end)
)
) then 1
else 0
end
from bas_cargo_space source_space
left join bas_cargo_area_config area_config
on area_config.warehouse_id = source_space.warehouse_id
and area_config.cargo_area_code = source_space.this_cargo_area
and area_config.is_active = '1'
where source_space.cargo_space_id = #{cargoSpaceId}
</select>
</mapper>

View File

@@ -60,6 +60,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where job_id = #{jobId}
and version_no = #{versionNo}
and dispatch_status in ('PENDING', 'BLOCKED', 'RETRY_WAIT', 'EXHAUSTED', 'SENDING')
and (dispatch_status != 'SENDING'
or last_attempt_time &lt;= dateadd(minute, -5, #{attemptTime}))
and (#{trigger} != 'AUTO_RETRY' or auto_retry_count &lt; 5)
</update>
@@ -67,27 +69,35 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
update wcs_job_dispatch
set dispatch_status = 'BLOCKED', last_error = #{lastError}, next_retry_time = #{nextRetryTime},
update_time = #{updateTime}, version_no = version_no + 1
where job_id = #{jobId} and dispatch_status != 'SUCCESS'
where job_id = #{jobId}
and version_no = #{versionNo}
and dispatch_status != 'SUCCESS'
</update>
<update id="markRetryWait" parameterType="map">
update wcs_job_dispatch
set dispatch_status = 'RETRY_WAIT', last_error = #{lastError}, next_retry_time = #{nextRetryTime},
update_time = #{updateTime}, version_no = version_no + 1
where job_id = #{jobId} and dispatch_status = 'SENDING'
where job_id = #{jobId}
and version_no = #{versionNo}
and dispatch_status = 'SENDING'
</update>
<update id="markSuccess" parameterType="map">
update wcs_job_dispatch
set dispatch_status = 'SUCCESS', last_error = null, next_retry_time = null,
success_time = #{successTime}, update_time = #{successTime}, version_no = version_no + 1
where job_id = #{jobId} and dispatch_status = 'SENDING'
where job_id = #{jobId}
and version_no = #{versionNo}
and dispatch_status = 'SENDING'
</update>
<update id="markExhausted" parameterType="map">
update wcs_job_dispatch
set dispatch_status = 'EXHAUSTED', last_error = #{lastError}, next_retry_time = null,
update_time = #{updateTime}, version_no = version_no + 1
where job_id = #{jobId} and dispatch_status = 'SENDING'
where job_id = #{jobId}
and version_no = #{versionNo}
and dispatch_status in ('SENDING', 'BLOCKED', 'RETRY_WAIT')
</update>
</mapper>

View File

@@ -35,6 +35,10 @@ class WcsClientTest {
.isEqualTo("MOCK_WCS_RETRIEVE_OK");
assertThat(client.sendRetrieveTask(outboundJob).getData())
.isEqualTo("MOCK_WCS_RETRIEVE_OK");
assertThat(client.sendLocationOutboundTask(outboundJob).getData())
.isEqualTo("MOCK_WCS_LOCATION_OUT_OK");
assertThat(client.sendRelocationTask(outboundJob).getData())
.isEqualTo("MOCK_WCS_RELOCATION_OK");
assertThat(client.sendCraneMove("CR01", "A0101", "A0102", "MOVE").getData())
.isEqualTo("MOCK_WCS_CRANE_MOVE_OK");
assertThat(client.cancelTask("TASK001").getData())
@@ -64,6 +68,10 @@ class WcsClientTest {
.isEqualTo("ACCEPTED");
assertThat(client.sendRetrieveTask(outboundJob).getData())
.isEqualTo("ACCEPTED");
assertThat(client.sendLocationOutboundTask(outboundJob).getData())
.isEqualTo("ACCEPTED");
assertThat(client.sendRelocationTask(outboundJob).getData())
.isEqualTo("ACCEPTED");
assertThat(client.queryDeviceStatus("CRANE").getData().get(0).get("status"))
.isEqualTo("ONLINE");
assertThat(client.sendCraneMove("CR01", "A0101", "A0102", "MOVE").getData())
@@ -75,7 +83,9 @@ class WcsClientTest {
"http://wcs/wcs/api/receiveWmsJob",
"http://wcs/wcs/api/receiveWmsJob",
"http://wcs/task/dispatch",
"http://wcs/wcs/api/receiveWmsJob",
"http://wcs/task/dispatch",
"http://wcs/location/dispatch",
"http://wcs/relocation/dispatch",
"http://wcs/device/status",
"http://wcs/crane/move",
"http://wcs/task/cancel");
@@ -84,6 +94,8 @@ class WcsClientTest {
MsgTypeEnum.JSON,
MsgTypeEnum.TEXT,
MsgTypeEnum.JSON,
MsgTypeEnum.JSON,
MsgTypeEnum.JSON,
MsgTypeEnum.TEXT,
MsgTypeEnum.TEXT,
MsgTypeEnum.TEXT);
@@ -219,6 +231,8 @@ class WcsClientTest {
Map<String, String> apis = new HashMap<>();
apis.put("sendPutawayTask", "/wcs/api/receiveWmsJob");
apis.put("sendRetrieveTask", "/task/dispatch");
apis.put("sendLocationOutboundTask", "/location/dispatch");
apis.put("sendRelocationTask", "/relocation/dispatch");
apis.put("queryDeviceStatus", "/device/status");
apis.put("sendCraneMove", "/crane/move");
apis.put("cancelTask", "/task/cancel");

View File

@@ -20,6 +20,8 @@ import com.ruoyi.in.stockin.domain.InStockin;
import com.ruoyi.in.stockin.mapper.InStockinMapper;
import com.ruoyi.out.stockout.mapper.OutStockoutMapper;
import com.ruoyi.out.stockoutdetail.mapper.OutStockoutDetailMapper;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
import com.ruoyi.out.dispatch.service.IOutboundDispatchService;
import com.ruoyi.pd.diff.mapper.PdDifferenceRecordMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -213,6 +215,9 @@ class MydoJobServiceImplTest {
@Mock
private PdDifferenceRecordMapper pdDifferenceRecordMapper;
@Mock
private IOutboundDispatchService outboundDispatchService;
@InjectMocks
private MydoJobServiceImpl service;
@@ -257,57 +262,16 @@ class MydoJobServiceImplTest {
}
@Test
void pushOutJobToWcsRejectsInboundJob() {
MydoJob job = new MydoJob();
job.setJobId("RK001");
job.setStatus("1");
job.setInOrOut(1L);
when(mydoJobMapper.selectMydoJobByJobId("RK001")).thenReturn(job);
AjaxResult result = service.pushOutJobToWcs("RK001");
assertThat(result.isError()).isTrue();
assertThat(result.get(AjaxResult.MSG_TAG)).isEqualTo("请选择出库作业");
verifyNoInteractions(wcsClient);
verify(mydoJobMapper, never()).updateMydoJob(any(MydoJob.class));
}
@Test
void pushOutJobToWcsDoesNotSendAlreadyPushedJobAgain() {
MydoJob job = new MydoJob();
job.setJobId("CK001");
job.setStatus("2");
job.setInOrOut(20L);
when(mydoJobMapper.selectMydoJobByJobId("CK001")).thenReturn(job);
AjaxResult result = service.pushOutJobToWcs("CK001");
assertThat(result.isSuccess()).isTrue();
assertThat(result.get(AjaxResult.MSG_TAG)).isEqualTo("出库作业已下发,无需重复推送");
verifyNoInteractions(wcsClient);
verify(mydoJobMapper, never()).updateMydoJob(any(MydoJob.class));
}
@Test
void pushOutJobToWcsMarksPendingOutJobAsPushedAfterWcsAcceptsIt() {
MydoJob job = new MydoJob();
job.setJobId("CK002");
job.setStatus("1");
job.setInOrOut(20L);
when(mydoJobMapper.selectMydoJobByJobId("CK002")).thenReturn(job);
when(wcsClient.sendRetrieveTask(job)).thenReturn(ApiResponse.ok("OK"));
when(mydoJobMapper.updateMydoJob(any(MydoJob.class))).thenReturn(1);
void pushOutJobToWcsDelegatesInitialDispatchForExistingBusinessCallers() {
AjaxResult expected = AjaxResult.success("WCS出库作业下发成功:OK");
when(outboundDispatchService.dispatch("CK002", DispatchTrigger.INITIAL)).thenReturn(expected);
AjaxResult result = service.pushOutJobToWcs("CK002");
assertThat(result.isSuccess()).isTrue();
ArgumentCaptor<MydoJob> updateCaptor = ArgumentCaptor.forClass(MydoJob.class);
verify(mydoJobMapper).updateMydoJob(updateCaptor.capture());
MydoJob update = updateCaptor.getValue();
assertThat(update.getJobId()).isEqualTo("CK002");
assertThat(update.getStatus()).isEqualTo("3");
assertThat(update.getExecutionPhase()).isEqualTo("1");
assertThat(update.getStartTime()).isNotBlank();
assertThat(result).isSameAs(expected);
verify(outboundDispatchService).dispatch("CK002", DispatchTrigger.INITIAL);
verifyNoInteractions(mydoJobMapper);
verifyNoInteractions(wcsClient);
}
@Test

View File

@@ -26,6 +26,7 @@ class WcsJobDispatchMapperSqlTest
.contains("EXHAUSTED")
.contains("auto_retry_count &lt; 5")
.contains("dispatch_status = 'SENDING'")
.contains("last_attempt_time &lt;= dateadd(minute, -5")
.contains("version_no = version_no + 1")
.contains("selectRetryCandidates")
.contains("claimForSend")

View File

@@ -0,0 +1,306 @@
package com.ruoyi.out.dispatch.service.impl;
import java.util.List;
import java.util.Map;
import com.ruoyi.api.client.WcsClient;
import com.ruoyi.api.domain.ApiResponse;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.in.job.domain.MydoJob;
import com.ruoyi.in.job.mapper.MydoJobMapper;
import com.ruoyi.out.dense.domain.DispatchEligibility;
import com.ruoyi.out.dense.service.IDenseLaneService;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
import com.ruoyi.out.dispatch.domain.WcsJobDispatch;
import com.ruoyi.out.dispatch.mapper.WcsJobDispatchMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.mock;
class OutboundDispatchServiceImplTest
{
private MydoJobMapper jobMapper;
private WcsJobDispatchMapper dispatchMapper;
private IDenseLaneService denseLaneService;
private WcsClient wcsClient;
private OutboundDispatchServiceImpl service;
@BeforeEach
void setUp()
{
jobMapper = mock(MydoJobMapper.class);
dispatchMapper = mock(WcsJobDispatchMapper.class);
denseLaneService = mock(IDenseLaneService.class);
wcsClient = mock(WcsClient.class);
service = new OutboundDispatchServiceImpl(jobMapper, dispatchMapper, denseLaneService, wcsClient);
}
@Test
void initialFailureKeepsBusinessSuccessAndStartsRetryCountAtZero()
{
MydoJob job = jobOfType("CK1", 21L);
stubClaim(job, dispatch("CK1", WcsJobDispatch.PENDING, 0));
when(wcsClient.sendRetrieveTask(job)).thenReturn(ApiResponse.fail("WCS_ERROR", "连接失败"));
AjaxResult result = service.dispatch("CK1", DispatchTrigger.INITIAL);
assertThat(result.isSuccess()).isTrue();
verify(dispatchMapper).markRetryWait(argThat(params ->
"CK1".equals(params.get("jobId"))
&& Integer.valueOf(0).equals(params.get("autoRetryCount"))
&& String.valueOf(params.get("lastError")).contains("连接失败")));
verify(jobMapper, never()).updateMydoJob(any(MydoJob.class));
}
@Test
void fifthAutomaticRetryFailureBecomesExhausted()
{
MydoJob job = jobOfType("CK2", 21L);
stubClaim(job, dispatch("CK2", WcsJobDispatch.RETRY_WAIT, 4));
when(wcsClient.sendRetrieveTask(job)).thenReturn(ApiResponse.fail("WCS_ERROR", "仍然失败"));
AjaxResult result = service.dispatch("CK2", DispatchTrigger.AUTO_RETRY);
assertThat(result.isSuccess()).isTrue();
verify(dispatchMapper).claimForSend(argThat(params ->
DispatchTrigger.AUTO_RETRY.name().equals(params.get("trigger"))));
verify(dispatchMapper).markExhausted(argThat(params ->
Integer.valueOf(5).equals(params.get("autoRetryCount"))));
verify(dispatchMapper, never()).markRetryWait(any(Map.class));
}
@Test
void automaticRetryBelowLimitReturnsToRetryWait()
{
MydoJob job = jobOfType("CK3", 21L);
stubClaim(job, dispatch("CK3", WcsJobDispatch.RETRY_WAIT, 3));
when(wcsClient.sendRetrieveTask(job)).thenReturn(ApiResponse.fail("WCS_ERROR", "暂时失败"));
service.dispatch("CK3", DispatchTrigger.AUTO_RETRY);
verify(dispatchMapper).markRetryWait(argThat(params ->
Integer.valueOf(4).equals(params.get("autoRetryCount"))));
verify(dispatchMapper, never()).markExhausted(any(Map.class));
}
@Test
void staleSendingRecordAtRetryLimitIsMarkedExhaustedWithoutCallingWcs()
{
MydoJob job = jobOfType("CK-STALE", 21L);
WcsJobDispatch dispatch = dispatch("CK-STALE", WcsJobDispatch.SENDING, 5);
dispatch.setLastAttemptTime(new java.util.Date(System.currentTimeMillis() - 6 * 60_000L));
when(jobMapper.selectMydoJobByJobId("CK-STALE")).thenReturn(job);
when(dispatchMapper.selectByJobId("CK-STALE")).thenReturn(dispatch);
AjaxResult result = service.dispatch("CK-STALE", DispatchTrigger.AUTO_RETRY);
assertThat(result.isSuccess()).isTrue();
verify(dispatchMapper).markExhausted(argThat(params ->
Integer.valueOf(5).equals(params.get("autoRetryCount"))));
verify(wcsClient, never()).sendRetrieveTask(any(MydoJob.class));
}
@Test
void manualDispatchCanRecoverAnExhaustedJob()
{
MydoJob job = jobOfType("CK4", 21L);
stubClaim(job, dispatch("CK4", WcsJobDispatch.EXHAUSTED, 5));
when(wcsClient.sendRetrieveTask(job)).thenReturn(ApiResponse.ok("OK"));
when(jobMapper.updateMydoJob(any(MydoJob.class))).thenReturn(1);
AjaxResult result = service.dispatch("CK4", DispatchTrigger.MANUAL);
assertThat(result.isSuccess()).isTrue();
verify(dispatchMapper).claimForSend(argThat(params ->
DispatchTrigger.MANUAL.name().equals(params.get("trigger"))));
verify(dispatchMapper).markSuccess(any(Map.class));
}
@Test
void successfulDispatchUpdatesBusinessJobOnlyAfterWcsAccepts()
{
MydoJob job = jobOfType("CK5", 21L);
stubClaim(job, dispatch("CK5", WcsJobDispatch.PENDING, 0));
when(wcsClient.sendRetrieveTask(job)).thenReturn(ApiResponse.ok("WCS-OK"));
when(jobMapper.updateMydoJob(any(MydoJob.class))).thenReturn(1);
AjaxResult result = service.dispatch("CK5", DispatchTrigger.INITIAL);
assertThat(result.isSuccess()).isTrue();
ArgumentCaptor<MydoJob> updateCaptor = ArgumentCaptor.forClass(MydoJob.class);
verify(jobMapper).updateMydoJob(updateCaptor.capture());
assertThat(updateCaptor.getValue().getJobId()).isEqualTo("CK5");
assertThat(updateCaptor.getValue().getStatus()).isEqualTo("3");
assertThat(updateCaptor.getValue().getExecutionPhase()).isEqualTo("1");
assertThat(updateCaptor.getValue().getStartTime()).isNotBlank();
verify(dispatchMapper).markSuccess(any(Map.class));
}
@Test
void rejectedAtomicClaimDoesNotCallWcs()
{
MydoJob job = jobOfType("CK6", 21L);
when(jobMapper.selectMydoJobByJobId("CK6")).thenReturn(job);
when(dispatchMapper.selectByJobId("CK6"))
.thenReturn(dispatch("CK6", WcsJobDispatch.RETRY_WAIT, 1));
when(denseLaneService.checkDispatchEligibility(job)).thenReturn(DispatchEligibility.allowed());
when(dispatchMapper.claimForSend(any(Map.class))).thenReturn(0);
AjaxResult result = service.dispatch("CK6", DispatchTrigger.AUTO_RETRY);
assertThat(result.isSuccess()).isTrue();
verify(wcsClient, never()).sendRetrieveTask(any(MydoJob.class));
}
@Test
void activeSendingRecordCannotBeClaimedAgain()
{
MydoJob job = jobOfType("CK-SENDING", 21L);
WcsJobDispatch dispatch = dispatch("CK-SENDING", WcsJobDispatch.SENDING, 1);
dispatch.setLastAttemptTime(new java.util.Date());
when(jobMapper.selectMydoJobByJobId("CK-SENDING")).thenReturn(job);
when(dispatchMapper.selectByJobId("CK-SENDING")).thenReturn(dispatch);
when(denseLaneService.checkDispatchEligibility(job)).thenReturn(DispatchEligibility.allowed());
when(dispatchMapper.claimForSend(any(Map.class))).thenReturn(1);
AjaxResult result = service.dispatch("CK-SENDING", DispatchTrigger.MANUAL);
assertThat(result.isSuccess()).isTrue();
verify(dispatchMapper, never()).claimForSend(any(Map.class));
verify(wcsClient, never()).sendRetrieveTask(any(MydoJob.class));
}
@Test
void blockedDenseJobDoesNotConsumeRetryOrCallWcs()
{
MydoJob job = jobOfType("CK7", 21L);
when(jobMapper.selectMydoJobByJobId("CK7")).thenReturn(job);
when(dispatchMapper.selectByJobId("CK7"))
.thenReturn(dispatch("CK7", WcsJobDispatch.RETRY_WAIT, 2));
when(denseLaneService.checkDispatchEligibility(job))
.thenReturn(DispatchEligibility.blocked("前方仍有2个托盘"));
AjaxResult result = service.dispatch("CK7", DispatchTrigger.AUTO_RETRY);
assertThat(result.isSuccess()).isTrue();
verify(dispatchMapper).markBlocked(argThat(params ->
"前方仍有2个托盘".equals(params.get("lastError"))));
verify(dispatchMapper, never()).claimForSend(any(Map.class));
verify(wcsClient, never()).sendRetrieveTask(any(MydoJob.class));
}
@Test
void firstActualSendAfterBlockedStateDoesNotCountAsAutomaticRetry()
{
MydoJob job = jobOfType("CK-BLOCKED-FIRST", 21L);
WcsJobDispatch dispatch = dispatch("CK-BLOCKED-FIRST", WcsJobDispatch.BLOCKED, 0);
stubClaim(job, dispatch);
when(wcsClient.sendRetrieveTask(job)).thenReturn(ApiResponse.fail("WCS_ERROR", "首次调用失败"));
AjaxResult result = service.dispatch("CK-BLOCKED-FIRST", DispatchTrigger.AUTO_RETRY);
assertThat(result.isSuccess()).isTrue();
verify(dispatchMapper).claimForSend(argThat(params ->
DispatchTrigger.INITIAL.name().equals(params.get("trigger"))));
verify(dispatchMapper).markRetryWait(argThat(params ->
Integer.valueOf(0).equals(params.get("autoRetryCount"))));
}
@ParameterizedTest
@ValueSource(longs = {20L, 21L, 23L})
void routesOutboundByJobType(long jobType)
{
String jobId = "CK-TYPE-" + jobType;
MydoJob job = jobOfType(jobId, jobType);
stubClaim(job, dispatch(jobId, WcsJobDispatch.PENDING, 0));
when(jobMapper.updateMydoJob(any(MydoJob.class))).thenReturn(1);
when(wcsClient.sendLocationOutboundTask(job)).thenReturn(ApiResponse.ok("LOCATION-OK"));
when(wcsClient.sendRetrieveTask(job)).thenReturn(ApiResponse.ok("RETRIEVE-OK"));
when(wcsClient.sendRelocationTask(job)).thenReturn(ApiResponse.ok("MOVE-OK"));
service.dispatch(jobId, DispatchTrigger.INITIAL);
if (jobType == 20L) {
verify(wcsClient).sendLocationOutboundTask(job);
verify(wcsClient, never()).sendRetrieveTask(job);
verify(wcsClient, never()).sendRelocationTask(job);
} else if (jobType == 23L) {
verify(wcsClient).sendRelocationTask(job);
verify(wcsClient, never()).sendRetrieveTask(job);
verify(wcsClient, never()).sendLocationOutboundTask(job);
} else {
verify(wcsClient).sendRetrieveTask(job);
verify(wcsClient, never()).sendLocationOutboundTask(job);
verify(wcsClient, never()).sendRelocationTask(job);
}
reset(wcsClient);
}
@Test
void retryTaskDispatchesEveryCandidateAsAutomaticRetry()
{
WcsJobDispatch first = dispatch("CK8", WcsJobDispatch.RETRY_WAIT, 1);
WcsJobDispatch second = dispatch("CK9", WcsJobDispatch.BLOCKED, 0);
when(dispatchMapper.selectRetryCandidates(any(), any(Integer.class)))
.thenReturn(List.of(first, second));
MydoJob firstJob = jobOfType("CK8", 21L);
MydoJob secondJob = jobOfType("CK9", 21L);
when(jobMapper.selectMydoJobByJobId("CK8")).thenReturn(firstJob);
when(jobMapper.selectMydoJobByJobId("CK9")).thenReturn(secondJob);
when(dispatchMapper.selectByJobId("CK8")).thenReturn(first);
when(dispatchMapper.selectByJobId("CK9")).thenReturn(second);
when(denseLaneService.checkDispatchEligibility(any(MydoJob.class)))
.thenReturn(DispatchEligibility.allowed());
when(dispatchMapper.claimForSend(any(Map.class))).thenReturn(0);
int processed = service.retryFailedJobs();
assertThat(processed).isEqualTo(2);
verify(dispatchMapper).claimForSend(argThat(params -> "CK8".equals(params.get("jobId"))));
verify(dispatchMapper).claimForSend(argThat(params -> "CK9".equals(params.get("jobId"))));
}
private void stubClaim(MydoJob job, WcsJobDispatch dispatch)
{
when(jobMapper.selectMydoJobByJobId(job.getJobId())).thenReturn(job);
when(dispatchMapper.selectByJobId(job.getJobId())).thenReturn(dispatch);
when(denseLaneService.checkDispatchEligibility(job)).thenReturn(DispatchEligibility.allowed());
when(dispatchMapper.claimForSend(any(Map.class))).thenReturn(1);
}
private MydoJob jobOfType(String jobId, Long inOrOut)
{
MydoJob job = new MydoJob();
job.setJobId(jobId);
job.setInOrOut(inOrOut);
job.setStatus("1");
job.setExecutionPhase("0");
job.setWarehouseId("03");
job.setCargoSpaceId("CS-" + jobId);
return job;
}
private WcsJobDispatch dispatch(String jobId, String status, int autoRetryCount)
{
WcsJobDispatch dispatch = new WcsJobDispatch();
dispatch.setJobId(jobId);
dispatch.setDispatchStatus(status);
dispatch.setAutoRetryCount(autoRetryCount);
dispatch.setManualRetryCount(0);
dispatch.setVersionNo(0);
if (!WcsJobDispatch.PENDING.equals(status) && !WcsJobDispatch.BLOCKED.equals(status)) {
dispatch.setLastAttemptTime(new java.util.Date());
}
return dispatch;
}
}