feat: add ERP push failure recovery

This commit is contained in:
zgw
2026-09-15 14:31:16 +08:00
parent 58e9832b61
commit 67251a7562
13 changed files with 851 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
package com.ruoyi.erp.push.controller;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.erp.push.domain.ErpPushException;
import com.ruoyi.erp.push.domain.ErpPushRetryRequest;
import com.ruoyi.erp.push.service.IErpPushExceptionService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/erp/push-exception")
public class ErpPushExceptionController extends BaseController {
private final IErpPushExceptionService service;
public ErpPushExceptionController(IErpPushExceptionService service) {
this.service = service;
}
@PreAuthorize("@ss.hasPermi('erp:pushException:list')")
@GetMapping("/list")
public TableDataInfo list(ErpPushException query) {
startPage();
List<ErpPushException> list = service.selectFailureList(query);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('erp:pushException:retry')")
@Log(title = "ERP推送异常重推", businessType = BusinessType.UPDATE)
@PostMapping("/retry")
public AjaxResult retry(@RequestBody ErpPushRetryRequest request) {
if (service.retry(request)) {
return AjaxResult.success("已加入重试队列");
}
return AjaxResult.error("单据状态已变化,可能已被处理,请刷新列表");
}
}

View File

@@ -0,0 +1,63 @@
package com.ruoyi.erp.push.domain;
import com.ruoyi.common.core.domain.BaseEntity;
public class ErpPushException extends BaseEntity {
private static final long serialVersionUID = 1L;
private String documentType;
private String recordId;
private String documentNo;
private String businessReference;
private String failureTime;
private String errorMessage;
public String getDocumentType() {
return documentType;
}
public void setDocumentType(String documentType) {
this.documentType = documentType;
}
public String getRecordId() {
return recordId;
}
public void setRecordId(String recordId) {
this.recordId = recordId;
}
public String getDocumentNo() {
return documentNo;
}
public void setDocumentNo(String documentNo) {
this.documentNo = documentNo;
}
public String getBusinessReference() {
return businessReference;
}
public void setBusinessReference(String businessReference) {
this.businessReference = businessReference;
}
public String getFailureTime() {
return failureTime;
}
public void setFailureTime(String failureTime) {
this.failureTime = failureTime;
}
public String getErrorMessage() {
return errorMessage;
}
public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}
}

View File

@@ -0,0 +1,22 @@
package com.ruoyi.erp.push.domain;
public class ErpPushRetryRequest {
private String documentType;
private String recordId;
public String getDocumentType() {
return documentType;
}
public void setDocumentType(String documentType) {
this.documentType = documentType;
}
public String getRecordId() {
return recordId;
}
public void setRecordId(String recordId) {
this.recordId = recordId;
}
}

View File

@@ -0,0 +1,20 @@
package com.ruoyi.erp.push.mapper;
import com.ruoyi.erp.push.domain.ErpPushException;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface ErpPushExceptionMapper {
List<ErpPushException> selectFailureList(ErpPushException query);
int requeueInboundHeader(@Param("recordId") String recordId);
int requeueInboundDetails(@Param("recordId") String recordId);
int requeueOutboundHeader(@Param("recordId") String recordId);
int requeueOutboundDetails(@Param("recordId") String recordId);
int requeueMove(@Param("recordId") String recordId);
}

View File

@@ -0,0 +1,12 @@
package com.ruoyi.erp.push.service;
import com.ruoyi.erp.push.domain.ErpPushException;
import com.ruoyi.erp.push.domain.ErpPushRetryRequest;
import java.util.List;
public interface IErpPushExceptionService {
List<ErpPushException> selectFailureList(ErpPushException query);
boolean retry(ErpPushRetryRequest request);
}

View File

@@ -0,0 +1,69 @@
package com.ruoyi.erp.push.service.impl;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.erp.push.domain.ErpPushException;
import com.ruoyi.erp.push.domain.ErpPushRetryRequest;
import com.ruoyi.erp.push.mapper.ErpPushExceptionMapper;
import com.ruoyi.erp.push.service.IErpPushExceptionService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Locale;
@Service
public class ErpPushExceptionServiceImpl implements IErpPushExceptionService {
private final ErpPushExceptionMapper mapper;
public ErpPushExceptionServiceImpl(ErpPushExceptionMapper mapper) {
this.mapper = mapper;
}
@Override
public List<ErpPushException> selectFailureList(ErpPushException query) {
return mapper.selectFailureList(query);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean retry(ErpPushRetryRequest request) {
if (request == null || StringUtils.isEmpty(request.getDocumentType())
|| StringUtils.isEmpty(request.getRecordId())) {
throw new ServiceException("单据类型和记录编号不能为空");
}
String type = request.getDocumentType().trim().toUpperCase(Locale.ROOT);
String recordId = request.getRecordId().trim();
switch (type) {
case "INBOUND":
return retryInbound(recordId);
case "OUTBOUND":
return retryOutbound(recordId);
case "MOVE":
return mapper.requeueMove(recordId) == 1;
default:
throw new ServiceException("不支持的单据类型: " + request.getDocumentType());
}
}
private boolean retryInbound(String recordId) {
if (mapper.requeueInboundHeader(recordId) != 1) {
return false;
}
if (mapper.requeueInboundDetails(recordId) < 1) {
throw new ServiceException("入库单缺少可重推的失败明细,已取消本次操作");
}
return true;
}
private boolean retryOutbound(String recordId) {
if (mapper.requeueOutboundHeader(recordId) != 1) {
return false;
}
if (mapper.requeueOutboundDetails(recordId) < 1) {
throw new ServiceException("出库单缺少可重推的失败明细,已取消本次操作");
}
return true;
}
}

View File

@@ -0,0 +1,107 @@
<?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.erp.push.mapper.ErpPushExceptionMapper">
<resultMap id="ErpPushExceptionResult" type="com.ruoyi.erp.push.domain.ErpPushException">
<result property="documentType" column="document_type"/>
<result property="recordId" column="record_id"/>
<result property="documentNo" column="document_no"/>
<result property="businessReference" column="business_reference"/>
<result property="failureTime" column="failure_time"/>
<result property="errorMessage" column="error_message"/>
</resultMap>
<sql id="failureUnion">
SELECT 'INBOUND' AS document_type,
CONVERT(nvarchar(100), stockin_guid) AS record_id,
CONVERT(nvarchar(100), stockin_id) AS document_no,
CONVERT(nvarchar(300), stockin_id) AS business_reference,
CONVERT(nvarchar(30), send_time) AS failure_time,
CONVERT(nvarchar(max), msg) AS error_message
FROM in_stockin_back
WHERE send_status = '2' AND stock_source = '1'
AND TRY_CONVERT(datetime2, send_time, 120) &gt;= DATEADD(month, -1, SYSDATETIME())
UNION ALL
SELECT 'OUTBOUND',
CONVERT(nvarchar(100), stockout_guid),
CONVERT(nvarchar(100), stockout_id),
CONVERT(nvarchar(300), stockout_id),
CONVERT(nvarchar(30), send_time),
CONVERT(nvarchar(max), msg)
FROM out_stockout_back
WHERE send_status = '2' AND stockout_source = '1'
AND TRY_CONVERT(datetime2, send_time, 120) &gt;= DATEADD(month, -1, SYSDATETIME())
UNION ALL
SELECT 'MOVE',
CONVERT(nvarchar(100), id),
CONVERT(nvarchar(100), id),
CONCAT(ISNULL(tray_code, ''), ' ', ISNULL(old_space, ''), ' -> ', ISNULL(new_space, '')),
CONVERT(nvarchar(30), send_time),
CONVERT(nvarchar(max), msg)
FROM move_msg
WHERE status = '2'
AND TRY_CONVERT(datetime2, send_time, 120) &gt;= DATEADD(month, -1, SYSDATETIME())
</sql>
<select id="selectFailureList" parameterType="com.ruoyi.erp.push.domain.ErpPushException"
resultMap="ErpPushExceptionResult">
SELECT failures.document_type,
failures.record_id,
failures.document_no,
failures.business_reference,
failures.failure_time,
failures.error_message
FROM (
<include refid="failureUnion"/>
) failures
<where>
<if test="documentType != null and documentType != ''">
AND failures.document_type = #{documentType}
</if>
<if test="documentNo != null and documentNo != ''">
AND failures.document_no LIKE CONCAT('%', #{documentNo}, '%')
</if>
<if test="errorMessage != null and errorMessage != ''">
AND failures.error_message LIKE CONCAT('%', #{errorMessage}, '%')
</if>
</where>
ORDER BY failures.failure_time DESC, failures.record_id DESC
</select>
<update id="requeueInboundHeader">
UPDATE in_stockin_back
SET send_status = '0', send_time = NULL, msg = N'已手动加入重试队列'
WHERE stockin_guid = #{recordId} AND send_status = '2' AND stock_source = '1'
AND TRY_CONVERT(datetime2, send_time, 120) &gt;= DATEADD(month, -1, SYSDATETIME())
</update>
<update id="requeueInboundDetails">
UPDATE in_stockin_detail_back
SET send_status = '0', send_time = NULL, msg = N'已手动加入重试队列'
WHERE stockin_guid = #{recordId} AND send_status = '2'
AND TRY_CONVERT(datetime2, send_time, 120) &gt;= DATEADD(month, -1, SYSDATETIME())
</update>
<update id="requeueOutboundHeader">
UPDATE out_stockout_back
SET send_status = '0', send_time = NULL, msg = N'已手动加入重试队列'
WHERE stockout_guid = #{recordId} AND send_status = '2' AND stockout_source = '1'
AND TRY_CONVERT(datetime2, send_time, 120) &gt;= DATEADD(month, -1, SYSDATETIME())
</update>
<update id="requeueOutboundDetails">
UPDATE out_stockout_detail_back
SET send_status = '0', send_time = NULL, msg = N'已手动加入重试队列'
WHERE stockout_guid = #{recordId} AND send_status = '2'
AND TRY_CONVERT(datetime2, send_time, 120) &gt;= DATEADD(month, -1, SYSDATETIME())
</update>
<update id="requeueMove">
UPDATE move_msg
SET status = '0', send_time = NULL, msg = N'已手动加入重试队列'
WHERE id = #{recordId} AND status = '2'
AND TRY_CONVERT(datetime2, send_time, 120) &gt;= DATEADD(month, -1, SYSDATETIME())
</update>
</mapper>

View File

@@ -292,6 +292,10 @@ class ShadowV3OperationsPackageTest {
CmdResult result = runProcess(new ProcessBuilder(
"git", "clone", "--quiet", root.toString(), destination.toString()), 60);
assertThat(result.exitCode()).as(result.output()).isZero();
CmdResult branch = runProcess(new ProcessBuilder(
"git", "-C", destination.toString(), "branch", "-m",
"test/ft546wms-shadow-v3-20260903"), 15);
assertThat(branch.exitCode()).as(branch.output()).isZero();
}
private String gitHead(Path repository) throws Exception {

View File

@@ -0,0 +1,81 @@
package com.ruoyi.erp.push.mapper;
import com.ruoyi.erp.push.domain.ErpPushException;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
class ErpPushExceptionMapperSqlTest {
private static final String RESOURCE = "mapper/erp/ErpPushExceptionMapper.xml";
private static final String NAMESPACE =
"com.ruoyi.erp.push.mapper.ErpPushExceptionMapper.";
private static final Pattern FROM_TABLE = Pattern.compile("\\bfrom\\s+([a-z0-9_]+)");
private static final String RECENT_MONTH_PREDICATE =
"try_convert(datetime2, send_time, 120) >= dateadd(month, -1, sysdatetime())";
@Test
void failureListReadsOnlyRecentCurrentPushDocumentTables() throws Exception {
String sql = boundSql("selectFailureList");
assertThat(fromTables(sql)).containsExactly(
"in_stockin_back",
"out_stockout_back",
"move_msg");
assertThat(occurrences(sql, RECENT_MONTH_PREDICATE)).isEqualTo(3);
assertThat(sql).doesNotContain("request_log", "history", "_finish");
}
@Test
void retryUpdatesOnlyRequeueCurrentFailedDocumentsFromTheRecentMonth() throws Exception {
assertThat(boundSql("requeueInboundHeader"))
.contains("update in_stockin_back", "set send_status = '0'", "send_status = '2'",
"stock_source = '1'", RECENT_MONTH_PREDICATE);
assertThat(boundSql("requeueInboundDetails"))
.contains("update in_stockin_detail_back", "set send_status = '0'", "send_status = '2'",
RECENT_MONTH_PREDICATE);
assertThat(boundSql("requeueOutboundHeader"))
.contains("update out_stockout_back", "set send_status = '0'", "send_status = '2'",
"stockout_source = '1'", RECENT_MONTH_PREDICATE);
assertThat(boundSql("requeueOutboundDetails"))
.contains("update out_stockout_detail_back", "set send_status = '0'", "send_status = '2'",
RECENT_MONTH_PREDICATE);
assertThat(boundSql("requeueMove"))
.contains("update move_msg", "set status = '0'", "status = '2'", RECENT_MONTH_PREDICATE);
}
private String boundSql(String statementId) throws Exception {
Configuration configuration = new Configuration();
try (InputStream stream = Objects.requireNonNull(
getClass().getClassLoader().getResourceAsStream(RESOURCE), RESOURCE)) {
new XMLMapperBuilder(stream, configuration, RESOURCE, configuration.getSqlFragments()).parse();
}
return configuration.getMappedStatement(NAMESPACE + statementId)
.getBoundSql(new ErpPushException())
.getSql()
.replaceAll("\\s+", " ")
.toLowerCase(Locale.ROOT);
}
private List<String> fromTables(String sql) {
List<String> tables = new ArrayList<>();
Matcher matcher = FROM_TABLE.matcher(sql);
while (matcher.find()) {
tables.add(matcher.group(1));
}
return tables;
}
private int occurrences(String value, String token) {
return (value.length() - value.replace(token, "").length()) / token.length();
}
}

View File

@@ -0,0 +1,100 @@
package com.ruoyi.erp.push.service;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.erp.push.domain.ErpPushRetryRequest;
import com.ruoyi.erp.push.mapper.ErpPushExceptionMapper;
import com.ruoyi.erp.push.service.impl.ErpPushExceptionServiceImpl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ErpPushExceptionServiceTest {
@Mock
private ErpPushExceptionMapper mapper;
private ErpPushExceptionServiceImpl service;
@BeforeEach
void setUp() {
service = new ErpPushExceptionServiceImpl(mapper);
}
@Test
void requeuesInboundHeaderAndDetailsTogether() {
when(mapper.requeueInboundHeader("IN-GUID")).thenReturn(1);
when(mapper.requeueInboundDetails("IN-GUID")).thenReturn(2);
assertTrue(service.retry(request("INBOUND", "IN-GUID")));
verify(mapper).requeueInboundHeader("IN-GUID");
verify(mapper).requeueInboundDetails("IN-GUID");
}
@Test
void rejectsInboundRetryWhenNoFailedDetailsExist() {
when(mapper.requeueInboundHeader("IN-GUID")).thenReturn(1);
when(mapper.requeueInboundDetails("IN-GUID")).thenReturn(0);
assertThrows(ServiceException.class,
() -> service.retry(request("INBOUND", "IN-GUID")));
}
@Test
void staleFailureIsNotRequeuedTwice() {
when(mapper.requeueOutboundHeader("OUT-GUID")).thenReturn(0);
assertFalse(service.retry(request("OUTBOUND", "OUT-GUID")));
verify(mapper, never()).requeueOutboundDetails("OUT-GUID");
}
@Test
void requeuesOutboundHeaderAndDetailsTogether() {
when(mapper.requeueOutboundHeader("OUT-GUID")).thenReturn(1);
when(mapper.requeueOutboundDetails("OUT-GUID")).thenReturn(3);
assertTrue(service.retry(request("OUTBOUND", "OUT-GUID")));
verify(mapper).requeueOutboundDetails("OUT-GUID");
}
@Test
void requeuesMoveWithoutTouchingDocumentDetails() {
when(mapper.requeueMove("MOVE-ID")).thenReturn(1);
assertTrue(service.retry(request("MOVE", "MOVE-ID")));
verify(mapper).requeueMove("MOVE-ID");
}
@Test
void rejectsUnknownDocumentType() {
assertThrows(ServiceException.class,
() -> service.retry(request("OTHER", "ID")));
}
@Test
void rejectsBlankDocumentIdentityBeforeCallingMapper() {
assertThrows(ServiceException.class,
() -> service.retry(request("INBOUND", " ")));
verify(mapper, never()).requeueInboundHeader(org.mockito.ArgumentMatchers.anyString());
}
private ErpPushRetryRequest request(String documentType, String recordId) {
ErpPushRetryRequest request = new ErpPushRetryRequest();
request.setDocumentType(documentType);
request.setRecordId(recordId);
return request;
}
}

View File

@@ -0,0 +1,17 @@
import request from '@/utils/request'
export function listPushExceptions(query) {
return request({
url: '/erp/push-exception/list',
method: 'get',
params: query
})
}
export function retryPushException(data) {
return request({
url: '/erp/push-exception/retry',
method: 'post',
data
})
}

View File

@@ -0,0 +1,224 @@
<template>
<div class="app-container">
<el-form
ref="queryForm"
:model="queryParams"
size="small"
:inline="true"
v-show="showSearch"
label-width="76px"
>
<el-form-item label="单据类型" prop="documentType">
<el-select v-model="queryParams.documentType" placeholder="全部类型" clearable>
<el-option label="入库" value="INBOUND" />
<el-option label="出库" value="OUTBOUND" />
<el-option label="移库" value="MOVE" />
</el-select>
</el-form-item>
<el-form-item label="单据编号" prop="documentNo">
<el-input
v-model="queryParams.documentNo"
placeholder="请输入单据编号"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="错误信息" prop="errorMessage">
<el-input
v-model="queryParams.errorMessage"
placeholder="请输入错误信息"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="el-icon-refresh-right"
size="mini"
:disabled="selectedRows.length === 0 || retrying"
v-hasPermi="['erp:pushException:retry']"
@click="handleBatchRetry"
>批量重推</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
</el-row>
<el-alert
class="retry-alert"
title="仅显示最近一个月的失败单据。通信中断时 ERP 可能已经收到单据,重推前请人工确认。"
type="warning"
:closable="false"
show-icon
/>
<el-table v-loading="loading" :data="exceptionList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="48" align="center" />
<el-table-column label="类型" prop="documentType" width="84" align="center">
<template slot-scope="scope">
<el-tag size="small" :type="typeTag(scope.row.documentType)">
{{ typeLabel(scope.row.documentType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="单据编号" prop="documentNo" min-width="170" show-overflow-tooltip />
<el-table-column label="业务信息" prop="businessReference" min-width="210" show-overflow-tooltip />
<el-table-column label="失败时间" prop="failureTime" width="168" />
<el-table-column label="错误原因" prop="errorMessage" min-width="220" show-overflow-tooltip />
<el-table-column label="操作" width="150" fixed="right" align="center">
<template slot-scope="scope">
<el-button
type="text"
size="mini"
icon="el-icon-refresh-right"
:disabled="retrying"
:loading="retryingId === rowKey(scope.row)"
v-hasPermi="['erp:pushException:retry']"
@click="handleRetry(scope.row)"
>重推</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
</template>
<script>
import { listPushExceptions, retryPushException } from '@/api/erp/pushException'
export default {
name: 'ErpPushException',
data() {
return {
loading: false,
retrying: false,
retryingId: null,
showSearch: true,
total: 0,
exceptionList: [],
selectedRows: [],
queryParams: {
pageNum: 1,
pageSize: 10,
documentType: null,
documentNo: null,
errorMessage: null
}
}
},
created() {
this.getList()
},
methods: {
getList() {
this.loading = true
return listPushExceptions(this.queryParams).then(response => {
this.exceptionList = response.rows || []
this.total = response.total || 0
}).finally(() => {
this.loading = false
})
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.resetForm('queryForm')
this.handleQuery()
},
handleSelectionChange(selection) {
this.selectedRows = selection
},
handleRetry(row) {
this.confirmRetry(1).then(() => {
this.retrying = true
this.retryingId = this.rowKey(row)
return retryPushException(this.retryPayload(row))
}).then(() => {
this.$modal.msgSuccess('已加入重试队列')
return this.getList()
}).catch(() => {
if (this.retryingId) {
return this.getList().catch(() => {})
}
}).finally(() => {
this.retrying = false
this.retryingId = null
})
},
async handleBatchRetry() {
const rows = this.selectedRows.slice()
try {
await this.confirmRetry(rows.length)
} catch (error) {
return
}
this.retrying = true
let succeeded = 0
const failed = []
try {
for (const row of rows) {
try {
await retryPushException(this.retryPayload(row))
succeeded += 1
} catch (error) {
failed.push(row.documentNo)
}
}
} finally {
this.retrying = false
await this.getList().catch(() => {})
}
if (failed.length === 0) {
this.$modal.msgSuccess(`已将 ${succeeded} 条单据加入重试队列`)
} else {
this.$modal.msgWarning(`成功 ${succeeded} 条,失败 ${failed.length} 条:${failed.join('、')}`)
}
},
confirmRetry(count) {
return this.$modal.confirm(
`确定重新推送 ${count} 条单据吗?如果 ERP 已收到但响应丢失,重复结果由 ERP 的单号校验规则决定。`
)
},
retryPayload(row) {
return {
documentType: row.documentType,
recordId: row.recordId
}
},
rowKey(row) {
return `${row.documentType}:${row.recordId}`
},
typeLabel(type) {
return { INBOUND: '入库', OUTBOUND: '出库', MOVE: '移库' }[type] || type
},
typeTag(type) {
return { INBOUND: 'success', OUTBOUND: 'danger', MOVE: 'warning' }[type] || 'info'
}
}
}
</script>
<style scoped>
.retry-alert {
margin-bottom: 12px;
}
</style>

View File

@@ -0,0 +1,86 @@
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
DECLARE @menu_id bigint;
DECLARE @retry_id bigint;
DECLARE @source_menu_id bigint;
DECLARE @lock_result int;
EXEC @lock_result = sys.sp_getapplock
@Resource = N'FT546WMS:upgrade-erp-push-exception-menu',
@LockMode = N'Exclusive',
@LockOwner = N'Transaction',
@LockTimeout = 10000;
IF @lock_result < 0
BEGIN
ROLLBACK TRANSACTION;
THROW 51000, N'无法获取 ERP 推送异常菜单升级锁', 1;
END;
SELECT TOP (1) @menu_id = menu_id
FROM dbo.sys_menu
WHERE parent_id = 2 AND path = N'pushException'
ORDER BY menu_id;
IF @menu_id IS NULL
BEGIN
SELECT @menu_id = ISNULL(MAX(menu_id), 0) + 1 FROM dbo.sys_menu WITH (UPDLOCK, HOLDLOCK);
INSERT INTO dbo.sys_menu
(menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
is_frame, is_cache, menu_type, visible, status, perms, icon,
create_by, create_time, update_by, update_time, remark)
VALUES
(@menu_id, N'ERP推送异常', 2, 2, N'pushException', N'erp/pushException/index', NULL, N'',
N'1', N'0', N'C', N'0', N'0', N'erp:pushException:list', N'warning',
N'admin', SYSDATETIME(), N'', NULL, N'ERP回传失败列表');
END;
SELECT TOP (1) @retry_id = menu_id
FROM dbo.sys_menu
WHERE parent_id = @menu_id AND perms = N'erp:pushException:retry'
ORDER BY menu_id;
IF @retry_id IS NULL
BEGIN
SELECT @retry_id = ISNULL(MAX(menu_id), 0) + 1 FROM dbo.sys_menu WITH (UPDLOCK, HOLDLOCK);
INSERT INTO dbo.sys_menu
(menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
is_frame, is_cache, menu_type, visible, status, perms, icon,
create_by, create_time, update_by, update_time, remark)
VALUES
(@retry_id, N'ERP异常重推', @menu_id, 1, N'#', N'', NULL, N'',
N'1', N'0', N'F', N'0', N'0', N'erp:pushException:retry', N'#',
N'admin', SYSDATETIME(), N'', NULL, N'失败单据重新加入ERP回传队列');
END;
SELECT TOP (1) @source_menu_id = menu_id
FROM dbo.sys_menu
WHERE perms = N'requestInterface:log:list'
ORDER BY menu_id;
INSERT INTO dbo.sys_role_menu (role_id, menu_id)
SELECT existing.role_id, @menu_id
FROM dbo.sys_role_menu existing
WHERE existing.menu_id = @source_menu_id
AND NOT EXISTS (
SELECT 1 FROM dbo.sys_role_menu target
WHERE target.role_id = existing.role_id AND target.menu_id = @menu_id
);
INSERT INTO dbo.sys_role_menu (role_id, menu_id)
SELECT existing.role_id, @retry_id
FROM dbo.sys_role_menu existing
WHERE existing.menu_id = @source_menu_id
AND NOT EXISTS (
SELECT 1 FROM dbo.sys_role_menu target
WHERE target.role_id = existing.role_id AND target.menu_id = @retry_id
);
COMMIT TRANSACTION;
SELECT N'ERP_PUSH_EXCEPTION_MENU_OK' AS result, @menu_id AS menu_id, @retry_id AS retry_menu_id;