feat: add manual WCS dispatch action

This commit is contained in:
zgw
2026-07-16 11:05:16 +08:00
parent 4641bddcea
commit 10ce28f8ac
5 changed files with 202 additions and 19 deletions

View File

@@ -74,7 +74,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="priority != null "> and priority = #{priority}</if> <if test="priority != null "> and priority = #{priority}</if>
<if test="height != null "> and height = #{height}</if> <if test="height != null "> and height = #{height}</if>
<if test="weight != null "> and weight = #{weight}</if> <if test="weight != null "> and weight = #{weight}</if>
<if test="params.beginCreateTime != null and params.beginCreateTime != '' and params.endCreateTime != null and params.endCreateTime != ''"> and create_time between #{params.beginCreateTime} and #{params.endCreateTime}</if> <if test="params.beginCreateTime != null and params.beginCreateTime != '' and params.endCreateTime != null and params.endCreateTime != ''"> and mydo_job.create_time between #{params.beginCreateTime} and #{params.endCreateTime}</if>
<if test="entranceId != null and entranceId != ''"> and entrance_id = #{entranceId}</if> <if test="entranceId != null and entranceId != ''"> and entrance_id = #{entranceId}</if>
<if test="exitId != null and exitId != ''"> and exit_id = #{exitId}</if> <if test="exitId != null and exitId != ''"> and exit_id = #{exitId}</if>
<if test="deptName != null and deptName != ''"> and dept_name like concat('%', #{deptName}, '%')</if> <if test="deptName != null and deptName != ''"> and dept_name like concat('%', #{deptName}, '%')</if>
@@ -430,7 +430,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="priority != null "> and priority = #{priority}</if> <if test="priority != null "> and priority = #{priority}</if>
<if test="height != null "> and height = #{height}</if> <if test="height != null "> and height = #{height}</if>
<if test="weight != null "> and weight = #{weight}</if> <if test="weight != null "> and weight = #{weight}</if>
<if test="params.beginCreateTime != null and params.beginCreateTime != '' and params.endCreateTime != null and params.endCreateTime != ''"> and create_time between #{params.beginCreateTime} and #{params.endCreateTime}</if> <if test="params.beginCreateTime != null and params.beginCreateTime != '' and params.endCreateTime != null and params.endCreateTime != ''"> and mydo_job.create_time between #{params.beginCreateTime} and #{params.endCreateTime}</if>
<if test="entranceId != null and entranceId != ''"> and entrance_id = #{entranceId}</if> <if test="entranceId != null and entranceId != ''"> and entrance_id = #{entranceId}</if>
<if test="exitId != null and exitId != ''"> and exit_id = #{exitId}</if> <if test="exitId != null and exitId != ''"> and exit_id = #{exitId}</if>
<if test="deptName != null and deptName != ''"> and dept_name like concat('%', #{deptName}, '%')</if> <if test="deptName != null and deptName != ''"> and dept_name like concat('%', #{deptName}, '%')</if>
@@ -484,7 +484,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="priority != null "> and priority = #{priority}</if> <if test="priority != null "> and priority = #{priority}</if>
<if test="height != null "> and height = #{height}</if> <if test="height != null "> and height = #{height}</if>
<if test="weight != null "> and weight = #{weight}</if> <if test="weight != null "> and weight = #{weight}</if>
<if test="params.beginCreateTime != null and params.beginCreateTime != '' and params.endCreateTime != null and params.endCreateTime != ''"> and create_time between #{params.beginCreateTime} and #{params.endCreateTime}</if> <if test="params.beginCreateTime != null and params.beginCreateTime != '' and params.endCreateTime != null and params.endCreateTime != ''"> and mydo_job.create_time between #{params.beginCreateTime} and #{params.endCreateTime}</if>
<if test="entranceId != null and entranceId != ''"> and entrance_id = #{entranceId}</if> <if test="entranceId != null and entranceId != ''"> and entrance_id = #{entranceId}</if>
<if test="exitId != null and exitId != ''"> and exit_id = #{exitId}</if> <if test="exitId != null and exitId != ''"> and exit_id = #{exitId}</if>
<if test="deptName != null and deptName != ''"> and dept_name like concat('%', #{deptName}, '%')</if> <if test="deptName != null and deptName != ''"> and dept_name like concat('%', #{deptName}, '%')</if>
@@ -594,7 +594,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="priority != null "> and priority = #{priority}</if> <if test="priority != null "> and priority = #{priority}</if>
<if test="height != null "> and height = #{height}</if> <if test="height != null "> and height = #{height}</if>
<if test="weight != null "> and weight = #{weight}</if> <if test="weight != null "> and weight = #{weight}</if>
<if test="params.beginCreateTime != null and params.beginCreateTime != '' and params.endCreateTime != null and params.endCreateTime != ''"> and create_time between #{params.beginCreateTime} and #{params.endCreateTime}</if> <if test="params.beginCreateTime != null and params.beginCreateTime != '' and params.endCreateTime != null and params.endCreateTime != ''"> and mydo_job.create_time between #{params.beginCreateTime} and #{params.endCreateTime}</if>
<if test="entranceId != null and entranceId != ''"> and entrance_id = #{entranceId}</if> <if test="entranceId != null and entranceId != ''"> and entrance_id = #{entranceId}</if>
<if test="exitId != null and exitId != ''"> and exit_id = #{exitId}</if> <if test="exitId != null and exitId != ''"> and exit_id = #{exitId}</if>
<if test="deptName != null and deptName != ''"> and dept_name like concat('%', #{deptName}, '%')</if> <if test="deptName != null and deptName != ''"> and dept_name like concat('%', #{deptName}, '%')</if>
@@ -622,7 +622,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="priority != null "> and priority = #{priority}</if> <if test="priority != null "> and priority = #{priority}</if>
<if test="height != null "> and height = #{height}</if> <if test="height != null "> and height = #{height}</if>
<if test="weight != null "> and weight = #{weight}</if> <if test="weight != null "> and weight = #{weight}</if>
<if test="params.beginCreateTime != null and params.beginCreateTime != '' and params.endCreateTime != null and params.endCreateTime != ''"> and create_time between #{params.beginCreateTime} and #{params.endCreateTime}</if> <if test="params.beginCreateTime != null and params.beginCreateTime != '' and params.endCreateTime != null and params.endCreateTime != ''"> and mydo_job.create_time between #{params.beginCreateTime} and #{params.endCreateTime}</if>
<if test="entranceId != null and entranceId != ''"> and entrance_id = #{entranceId}</if> <if test="entranceId != null and entranceId != ''"> and entrance_id = #{entranceId}</if>
<if test="exitId != null and exitId != ''"> and exit_id = #{exitId}</if> <if test="exitId != null and exitId != ''"> and exit_id = #{exitId}</if>
<if test="deptName != null and deptName != ''"> and dept_name like concat('%', #{deptName}, '%')</if> <if test="deptName != null and deptName != ''"> and dept_name like concat('%', #{deptName}, '%')</if>

View File

@@ -88,6 +88,19 @@ class MydoJobMapperSqlTest {
.contains("<if test=\"warehouseId != null and warehouseId != ''\"> and warehouse_id = #{warehouseId}</if>"); .contains("<if test=\"warehouseId != null and warehouseId != ''\"> and warehouse_id = #{warehouseId}</if>");
} }
@Test
void outboundListQualifiesCreateTimeAfterJoiningDispatchOutbox() throws Exception {
String xml = Files.readString(resolveMapperXml());
for (String selectId : new String[] {"selectMydoJobList", "selectInMydoJobList",
"selectInventoryInMydoJobList", "selectOutMydoJobList", "selectInventoryOutMydoJobList"}) {
String activeList = section(xml, "<select id=\"" + selectId + "\"", "</select>");
assertThat(activeList)
.as(selectId)
.contains("mydo_job.create_time between #{params.beginCreateTime} and #{params.endCreateTime}")
.doesNotContain("> and create_time between #{params.beginCreateTime}");
}
}
@Test @Test
void denseInboundJobClaimIsAtomicAndLimitedToPendingWarehouse03Jobs() throws Exception { void denseInboundJobClaimIsAtomicAndLimitedToPendingWarehouse03Jobs() throws Exception {
String xml = Files.readString(resolveMapperXml()); String xml = Files.readString(resolveMapperXml());

View File

@@ -180,6 +180,21 @@ class OutboundDispatchServiceImplTest
verify(wcsClient, never()).sendRetrieveTask(any(MydoJob.class)); verify(wcsClient, never()).sendRetrieveTask(any(MydoJob.class));
} }
@Test
void completedJobCannotBeManuallyDispatched()
{
MydoJob job = jobOfType("CK-COMPLETED", 21L);
job.setStatus("4");
when(jobMapper.selectMydoJobByJobId("CK-COMPLETED")).thenReturn(job);
AjaxResult result = service.dispatch("CK-COMPLETED", DispatchTrigger.MANUAL);
assertThat(result.isSuccess()).isFalse();
assertThat(result.get("msg")).asString().contains("状态不允许");
verify(dispatchMapper, never()).insertIfAbsent(any(String.class));
verify(wcsClient, never()).sendRetrieveTask(any(MydoJob.class));
}
@Test @Test
void blockedDenseJobDoesNotConsumeRetryOrCallWcs() void blockedDenseJobDoesNotConsumeRetryOrCallWcs()
{ {

View File

@@ -0,0 +1,62 @@
package com.ruoyi.out.job.controller;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
import com.ruoyi.out.dispatch.service.IOutboundDispatchService;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class OutMydoJobDispatchControllerTest
{
@Test
void manualEndpointUsesDedicatedPermissionAndManualTrigger() throws Exception
{
Method endpoint = OutMydoJobController.class.getMethod("pushWcs", String.class);
assertThat(endpoint.getAnnotation(PreAuthorize.class).value())
.isEqualTo("@ss.hasPermi('out:job:pushWcs')");
IOutboundDispatchService dispatchService = mock(IOutboundDispatchService.class);
when(dispatchService.dispatch("CK-1", DispatchTrigger.MANUAL))
.thenReturn(AjaxResult.success("已下发"));
OutMydoJobController controller = new OutMydoJobController();
ReflectionTestUtils.setField(controller, "outboundDispatchService", dispatchService);
assertThat(controller.pushWcs("CK-1").isSuccess()).isTrue();
verify(dispatchService).dispatch("CK-1", DispatchTrigger.MANUAL);
}
@Test
void jobPageShowsDispatchStateAndRestrictsManualAction() throws Exception
{
String vue = Files.readString(resolve("ruoyi-ui/src/views/out/job/index.vue"));
String api = Files.readString(resolve("ruoyi-ui/src/api/out/job.js"));
assertThat(vue)
.contains("prop=\"dispatchStatus\"")
.contains("prop=\"autoRetryCount\"")
.contains("lastDispatchError")
.contains("out:job:pushWcs")
.contains("canPushWcs")
.contains("手动下发 WCS")
.doesNotContain("v-hasPermi=\"['out:job:edit']\"");
assertThat(api).contains("/out/job/pushWcs/");
}
private Path resolve(String path)
{
return Stream.of(Path.of(path), Path.of("..", path))
.filter(Files::exists)
.findFirst()
.orElseThrow();
}
}

View File

@@ -109,15 +109,19 @@
>手动完成</el-button> >手动完成</el-button>
</el-col> </el-col>
<el-col :span="1.5"> <el-col :span="1.5">
<el-button <el-tooltip content="手动下发 WCS" placement="top">
type="primary" <span>
plain <el-button
icon="el-icon-upload2" type="primary"
size="mini" plain
:disabled="single" icon="el-icon-s-promotion"
@click="handlePushWcs" size="mini"
v-hasPermi="['out:job:edit']" :disabled="single || !canPushWcs(selectedRows[0])"
>推送WCS</el-button> @click="handlePushWcs"
v-hasPermi="['out:job:pushWcs']"
>手动下发 WCS</el-button>
</span>
</el-tooltip>
</el-col> </el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar> <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row> </el-row>
@@ -154,6 +158,29 @@
<span>{{ parseTime(scope.row.createTime, '{y}-{m}-{d} {h}:{i}:{s}') }}</span> <span>{{ parseTime(scope.row.createTime, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="WCS状态" align="center" prop="dispatchStatus" width="112">
<template slot-scope="scope">
<el-tag :type="dispatchStatusType(scope.row.dispatchStatus)" size="mini" effect="plain">
{{ dispatchStatusLabel(scope.row.dispatchStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="自动重试" align="center" prop="autoRetryCount" width="88">
<template slot-scope="scope">{{ scope.row.autoRetryCount || 0 }}</template>
</el-table-column>
<el-table-column label="最后下发错误" align="left" prop="lastDispatchError" min-width="160">
<template slot-scope="scope">
<el-tooltip
v-if="scope.row.lastDispatchError"
:content="scope.row.lastDispatchError"
placement="top"
effect="dark"
>
<span class="dispatch-error">{{ scope.row.lastDispatchError }}</span>
</el-tooltip>
<span v-else class="dispatch-empty">-</span>
</template>
</el-table-column>
<el-table-column label="开始时间" align="center" prop="startTime" width="160"> <el-table-column label="开始时间" align="center" prop="startTime" width="160">
<template slot-scope="scope"> <template slot-scope="scope">
<span>{{ parseTime(scope.row.startTime, '{y}-{m}-{d} {h}:{i}:{s}') }}</span> <span>{{ parseTime(scope.row.startTime, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
@@ -239,6 +266,8 @@ export default {
loading: true, loading: true,
// 选中数组 // 选中数组
ids: [], ids: [],
// 当前选中行
selectedRows: [],
// 非单个禁用 // 非单个禁用
single: true, single: true,
// 显示搜索条件 // 显示搜索条件
@@ -290,6 +319,8 @@ export default {
this.jobList = response.rows this.jobList = response.rows
this.total = response.total this.total = response.total
this.loading = false this.loading = false
}).catch(() => {
this.loading = false
}) })
}, },
/** 搜索按钮操作 */ /** 搜索按钮操作 */
@@ -313,6 +344,7 @@ export default {
// 多选框选中数据 // 多选框选中数据
handleSelectionChange(selection) { handleSelectionChange(selection) {
this.ids = selection.map(item => item.jobId) this.ids = selection.map(item => item.jobId)
this.selectedRows = selection
this.single = selection.length !== 1 this.single = selection.length !== 1
}, },
/** 手动作废按钮操作 */ /** 手动作废按钮操作 */
@@ -337,14 +369,59 @@ export default {
}, },
/** 推送 WCS 按钮操作 */ /** 推送 WCS 按钮操作 */
handlePushWcs() { handlePushWcs() {
const jobId = this.ids[0] const row = this.selectedRows[0]
this.$modal.confirm('是否确认推送选中的出库作业到WCS?').then(() => { if (!this.canPushWcs(row)) {
return pushJobToWcs(jobId) this.$modal.msgWarning('当前作业状态不允许手动下发 WCS')
}).then(() => { return
this.$modal.msgSuccess("推送成功") }
const autoRetries = Number(row.autoRetryCount || 0)
const manualRetries = Number(row.manualRetryCount || 0)
const lastError = row.lastDispatchError || '无'
const message = `确认手动下发作业 ${row.jobId} 到 WCS?自动重试 ${autoRetries} 次,人工下发 ${manualRetries} 次;最后错误:${lastError}`
this.$modal.confirm(message).then(() => {
return pushJobToWcs(row.jobId)
}).then(response => {
const resultMessage = response.msg || 'WCS手动下发请求已处理'
if (resultMessage.includes('下发成功')) {
this.$modal.msgSuccess(resultMessage)
} else {
this.$modal.msgWarning(resultMessage)
}
this.getList() this.getList()
}).catch(() => {}) }).catch(() => {})
}, },
canPushWcs(row) {
if (!row || String(row.status) !== '1') {
return false
}
return ['PENDING', 'RETRY_WAIT', 'EXHAUSTED', 'BLOCKED']
.includes(this.normalizeDispatchStatus(row.dispatchStatus))
},
normalizeDispatchStatus(status) {
return status || 'PENDING'
},
dispatchStatusLabel(status) {
const labels = {
PENDING: '待下发',
SENDING: '下发中',
BLOCKED: '通道阻挡',
RETRY_WAIT: '等待重试',
SUCCESS: '已下发',
EXHAUSTED: '重试耗尽'
}
return labels[this.normalizeDispatchStatus(status)] || status || '待下发'
},
dispatchStatusType(status) {
const types = {
PENDING: 'info',
SENDING: 'warning',
BLOCKED: 'warning',
RETRY_WAIT: 'warning',
SUCCESS: 'success',
EXHAUSTED: 'danger'
}
return types[this.normalizeDispatchStatus(status)] || 'info'
},
/** 点击主作业行 —— 联动刷新子作业 */ /** 点击主作业行 —— 联动刷新子作业 */
handleRowClick(row) { handleRowClick(row) {
this.currentJob = row this.currentJob = row
@@ -368,3 +445,19 @@ export default {
} }
} }
</script> </script>
<style scoped>
.dispatch-error {
display: inline-block;
max-width: 100%;
overflow: hidden;
color: #f56c6c;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.dispatch-empty {
color: #c0c4cc;
}
</style>