diff --git a/ruoyi-admin/src/main/java/com/ruoyi/api/client/WcsClient.java b/ruoyi-admin/src/main/java/com/ruoyi/api/client/WcsClient.java index bce4dd8..5183c7d 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/api/client/WcsClient.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/api/client/WcsClient.java @@ -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 sendOutboundTask(MydoJob job) { + return sendRetrieveTask(job); + } + + /** 下发指定货位出库任务。 */ + public ApiResponse sendLocationOutboundTask(MydoJob job) { + return sendJsonJob(job, "sendLocationOutboundTask", "WCS-下发指定货位出库任务", "MOCK_WCS_LOCATION_OUT_OK"); + } + + /** 下发密集库移库任务。 */ + public ApiResponse sendRelocationTask(MydoJob job) { + return sendJsonJob(job, "sendRelocationTask", "WCS-下发移库任务", "MOCK_WCS_RELOCATION_OK"); + } + // ── 接口3:查询设备状态 ─────────────────────────────────── public ApiResponse>> queryDeviceStatus(String deviceType) { @@ -161,6 +175,21 @@ public class WcsClient extends BaseExternalClient { return config().getApiUrl(apiKey, job == null ? null : job.getWarehouseId()); } + private ApiResponse 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])) { diff --git a/ruoyi-admin/src/main/java/com/ruoyi/in/job/service/impl/MydoJobServiceImpl.java b/ruoyi-admin/src/main/java/com/ruoyi/in/job/service/impl/MydoJobServiceImpl.java index d58a134..710ef33 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/in/job/service/impl/MydoJobServiceImpl.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/in/job/service/impl/MydoJobServiceImpl.java @@ -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 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); } /** diff --git a/ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DispatchEligibility.java b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DispatchEligibility.java new file mode 100644 index 0000000..4b488fa --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DispatchEligibility.java @@ -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; + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/out/dense/mapper/DenseLaneMapper.java b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/mapper/DenseLaneMapper.java new file mode 100644 index 0000000..c071044 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/mapper/DenseLaneMapper.java @@ -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); +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/out/dense/service/IDenseLaneService.java b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/service/IDenseLaneService.java new file mode 100644 index 0000000..fa2e5e9 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/service/IDenseLaneService.java @@ -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); +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/out/dense/service/impl/DenseLaneServiceImpl.java b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/service/impl/DenseLaneServiceImpl.java new file mode 100644 index 0000000..37a4240 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/service/impl/DenseLaneServiceImpl.java @@ -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("当前货位不是密集库通道最外侧货位"); + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/out/dispatch/domain/DispatchTrigger.java b/ruoyi-admin/src/main/java/com/ruoyi/out/dispatch/domain/DispatchTrigger.java new file mode 100644 index 0000000..b57e122 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/out/dispatch/domain/DispatchTrigger.java @@ -0,0 +1,11 @@ +package com.ruoyi.out.dispatch.domain; + +/** + * WCS 下发触发方式。 + */ +public enum DispatchTrigger +{ + INITIAL, + AUTO_RETRY, + MANUAL +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/out/dispatch/service/IOutboundDispatchService.java b/ruoyi-admin/src/main/java/com/ruoyi/out/dispatch/service/IOutboundDispatchService.java new file mode 100644 index 0000000..a77322b --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/out/dispatch/service/IOutboundDispatchService.java @@ -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(); +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/out/dispatch/service/impl/OutboundDispatchServiceImpl.java b/ruoyi-admin/src/main/java/com/ruoyi/out/dispatch/service/impl/OutboundDispatchServiceImpl.java new file mode 100644 index 0000000..4645788 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/out/dispatch/service/impl/OutboundDispatchServiceImpl.java @@ -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 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 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 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 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 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 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 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 markParams(String jobId, WcsJobDispatch dispatch, + String lastError, Date updateTime) + { + Map 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(); + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/out/dispatch/task/WcsOutboundRetryTask.java b/ruoyi-admin/src/main/java/com/ruoyi/out/dispatch/task/WcsOutboundRetryTask.java new file mode 100644 index 0000000..b76844a --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/out/dispatch/task/WcsOutboundRetryTask.java @@ -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(); + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/out/job/controller/OutMydoJobController.java b/ruoyi-admin/src/main/java/com/ruoyi/out/job/controller/OutMydoJobController.java index b1b0657..693b6ca 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/out/job/controller/OutMydoJobController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/out/job/controller/OutMydoJobController.java @@ -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); } } diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 7af0762..5d43891 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -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 diff --git a/ruoyi-admin/src/main/resources/mapper/out/DenseLaneMapper.xml b/ruoyi-admin/src/main/resources/mapper/out/DenseLaneMapper.xml new file mode 100644 index 0000000..0ef4e04 --- /dev/null +++ b/ruoyi-admin/src/main/resources/mapper/out/DenseLaneMapper.xml @@ -0,0 +1,62 @@ + + + + + + diff --git a/ruoyi-admin/src/main/resources/mapper/out/WcsJobDispatchMapper.xml b/ruoyi-admin/src/main/resources/mapper/out/WcsJobDispatchMapper.xml index bcbd3e6..999fe7c 100644 --- a/ruoyi-admin/src/main/resources/mapper/out/WcsJobDispatchMapper.xml +++ b/ruoyi-admin/src/main/resources/mapper/out/WcsJobDispatchMapper.xml @@ -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 <= dateadd(minute, -5, #{attemptTime})) and (#{trigger} != 'AUTO_RETRY' or auto_retry_count < 5) @@ -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 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 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 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') diff --git a/ruoyi-admin/src/test/java/com/ruoyi/api/client/WcsClientTest.java b/ruoyi-admin/src/test/java/com/ruoyi/api/client/WcsClientTest.java index 010ae0b..430a390 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/api/client/WcsClientTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/api/client/WcsClientTest.java @@ -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 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"); diff --git a/ruoyi-admin/src/test/java/com/ruoyi/in/job/service/impl/MydoJobServiceImplTest.java b/ruoyi-admin/src/test/java/com/ruoyi/in/job/service/impl/MydoJobServiceImplTest.java index 633b329..78ea87a 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/in/job/service/impl/MydoJobServiceImplTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/in/job/service/impl/MydoJobServiceImplTest.java @@ -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 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 diff --git a/ruoyi-admin/src/test/java/com/ruoyi/out/dispatch/mapper/WcsJobDispatchMapperSqlTest.java b/ruoyi-admin/src/test/java/com/ruoyi/out/dispatch/mapper/WcsJobDispatchMapperSqlTest.java index fe1f7b5..4721b92 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/out/dispatch/mapper/WcsJobDispatchMapperSqlTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/out/dispatch/mapper/WcsJobDispatchMapperSqlTest.java @@ -26,6 +26,7 @@ class WcsJobDispatchMapperSqlTest .contains("EXHAUSTED") .contains("auto_retry_count < 5") .contains("dispatch_status = 'SENDING'") + .contains("last_attempt_time <= dateadd(minute, -5") .contains("version_no = version_no + 1") .contains("selectRetryCandidates") .contains("claimForSend") diff --git a/ruoyi-admin/src/test/java/com/ruoyi/out/dispatch/service/impl/OutboundDispatchServiceImplTest.java b/ruoyi-admin/src/test/java/com/ruoyi/out/dispatch/service/impl/OutboundDispatchServiceImplTest.java new file mode 100644 index 0000000..47ef456 --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/out/dispatch/service/impl/OutboundDispatchServiceImplTest.java @@ -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 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; + } +}