Compare commits
5 Commits
8dbdd411bf
...
feature/ft
| Author | SHA1 | Date | |
|---|---|---|---|
| 2632a57d73 | |||
| 67251a7562 | |||
| 58e9832b61 | |||
| a0af05bfa2 | |||
| acc6994385 |
105
docs/superpowers/plans/2026-09-15-ft546wms-takeover-readiness.md
Normal file
105
docs/superpowers/plans/2026-09-15-ft546wms-takeover-readiness.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# FT546WMS Takeover Readiness Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Produce a testable candidate that reports equivalent outbound failures correctly, refreshes reliably, and never dispatches a blocked rear outbound before its relocation completes.
|
||||
|
||||
**Architecture:** Keep Legacy as the production executor while hardening observation and dispatch safety. Separate refresh timeout from request comparison timeout; classify outbound comparisons by business meaning; enforce the relocation dependency both before WMS HTTP dispatch and, where the WCS contract permits, before WCS execution. ACTIVE takeover remains disabled until a dedicated mutation adapter and field evidence exist.
|
||||
|
||||
**Tech Stack:** Java 17, Spring Boot, MyBatis, SQL Server 2016, JUnit 5, Mockito, PowerShell integration fixtures.
|
||||
|
||||
**Spec:** User instruction in this thread: relocation precedes associated outbound; completion and inventory update release outbound; WCS must guard bypasses; unrelated channels continue normally.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No production switch, restart, synthetic production task, or business-data mutation during implementation.
|
||||
- Existing mixed-pilot lane scope and Legacy allocation behavior remain unchanged.
|
||||
- Every behavior change follows red-green testing and is independently revertible.
|
||||
- SQL Server 2016 syntax only; no online dependency downloads are assumed.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Outbound comparison semantics
|
||||
|
||||
**Files:**
|
||||
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java`
|
||||
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `OutboundShadowObservation.resultCode()` and Legacy numeric result code.
|
||||
- Produces: stable `MATCH`, `DIFFERENT`, `NOT_COMPARABLE` comparison codes while retaining raw evidence in `reason`.
|
||||
|
||||
- [ ] Add a failing test proving shadow `ERROR_STOCK_NOT_FOUND` plus Legacy `3` is an equivalent no-inventory outcome, and successful/technical/skipped cases are not falsely matched.
|
||||
- [ ] Run only `ShadowPlanningEngineTest` and observe the exact `LEGACY_3` failure.
|
||||
- [ ] Add a private outbound comparison classifier; do not alter planning or Legacy execution.
|
||||
- [ ] Re-run focused and shadow-module tests.
|
||||
- [ ] Commit independently.
|
||||
|
||||
### Task 2: Dedicated refresh timeout
|
||||
|
||||
**Files:**
|
||||
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowConfigurationTest.java`
|
||||
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java`
|
||||
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowObservationProperties.java`
|
||||
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java`
|
||||
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuard.java`
|
||||
- Modify: `ruoyi-admin/src/main/resources/application.yml`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `FORTUCKY_SHADOW_REFRESH_TIMEOUT_SECONDS`, default 10 seconds.
|
||||
- Produces: a refresh-only transaction timeout; the 3-second Legacy comparison deadline stays unchanged.
|
||||
|
||||
- [ ] Add failing property/guard tests and a transaction-manager test that distinguishes planning timeout from refresh timeout.
|
||||
- [ ] Run focused tests and observe missing property/incorrect timeout failure.
|
||||
- [ ] Use separate transaction templates or per-refresh timeout without sharing mutable template state.
|
||||
- [ ] Re-run focused and shadow-module tests.
|
||||
- [ ] Commit independently.
|
||||
|
||||
### Task 3: WMS dispatch dependency gate
|
||||
|
||||
**Files:**
|
||||
- Create or modify focused mapper/service files under `ruoyi-admin/src/main/java/com/ruoyi/api` and `ruoyi-admin/src/main/resources/mapper` after locating the existing job mapper contract.
|
||||
- Create: focused JUnit test for `ToWcsServiceImpl.pushOutMydoJob()`.
|
||||
- Modify: `scripts/mixed-pilot/tests/behavior.sql` only if an SQL regression is required by the confirmed race.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: candidate outbound job plus current lane obstruction / unresolved relocation state.
|
||||
- Produces: dispatch decision `READY` or `WAITING_FOR_RELOCATION`; only READY calls WCS and advances status.
|
||||
|
||||
- [ ] Add a failing service test: blocked rear outbound makes zero HTTP calls, unrelated/front outbounds still dispatch, and completing relocation makes the rear job dispatch exactly once.
|
||||
- [ ] Reproduce the task-creation race in the SQL scratch fixture if it bypasses the existing status-8 trigger.
|
||||
- [ ] Add the smallest read-only pre-dispatch guard and atomic claim/update boundary supported by current service patterns.
|
||||
- [ ] Re-run Java and SQL mixed-pilot regression suites.
|
||||
- [ ] Commit independently.
|
||||
|
||||
### Task 4: WCS defense and release candidate
|
||||
|
||||
**Files:**
|
||||
- Modify only the actual FT546-WCS receiving/execution module identified by repository audit.
|
||||
- Add focused WCS tests at its receive or execution seam.
|
||||
- Add deployment and rollback notes under `scripts/` in each affected repository.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: WMS job payload and WCS current/declared dependency evidence.
|
||||
- Produces: explicit accepted/waiting/rejected behavior without treating HTTP receipt as physical completion.
|
||||
|
||||
- [ ] Add a failing WCS test proving a blocked rear task cannot enter executable state, including manual/replayed payloads.
|
||||
- [ ] Implement the minimal defense compatible with the existing WMS/WCS contract; if WCS lacks authoritative obstruction data, reject ambiguous rear tasks rather than inventing state.
|
||||
- [ ] Run both repository test suites and inspect package contents offline.
|
||||
- [ ] Prepare versioned artifacts and rollback scripts; do not deploy or switch without a maintenance decision.
|
||||
- [ ] Request independent code review and address findings.
|
||||
|
||||
### Task 5: ACTIVE takeover gate
|
||||
|
||||
**Files:**
|
||||
- No production code change until Tasks 1-4 and field evidence pass.
|
||||
- Update readiness documentation with exact remaining mutation adapter and rollback requirements.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: verified planner result and explicit operator mode.
|
||||
- Produces: a future single-writer execution path, never simultaneous Legacy and algorithm allocation.
|
||||
|
||||
- [ ] Preserve `ShadowSafetyGuard` rejection of ACTIVE in this candidate.
|
||||
- [ ] Define endpoint-by-endpoint single-writer tests before adding any ACTIVE mutation adapter.
|
||||
- [ ] Require fresh inbound, successful outbound, no-inventory outbound, refresh and relocation field evidence before implementation approval.
|
||||
- [ ] Do not claim formal takeover readiness merely because the candidate builds.
|
||||
@@ -9,6 +9,7 @@ public class ShadowObservationProperties {
|
||||
private boolean enabled;
|
||||
private String warehouseId = "";
|
||||
private int timeoutSeconds = 3;
|
||||
private int refreshTimeoutSeconds = 10;
|
||||
private int snapshotMaxAgeSeconds = 60;
|
||||
private int quietIntervalMillis = 1000;
|
||||
|
||||
@@ -36,6 +37,14 @@ public class ShadowObservationProperties {
|
||||
this.timeoutSeconds = timeoutSeconds;
|
||||
}
|
||||
|
||||
public int getRefreshTimeoutSeconds() {
|
||||
return refreshTimeoutSeconds;
|
||||
}
|
||||
|
||||
public void setRefreshTimeoutSeconds(int refreshTimeoutSeconds) {
|
||||
this.refreshTimeoutSeconds = refreshTimeoutSeconds;
|
||||
}
|
||||
|
||||
public int getSnapshotMaxAgeSeconds() {
|
||||
return snapshotMaxAgeSeconds;
|
||||
}
|
||||
|
||||
@@ -35,17 +35,19 @@ public class ShadowPlanningEngine {
|
||||
private final SchedulingFacade schedulingFacade;
|
||||
private final ShadowInputMapper inputMapper;
|
||||
private final ShadowObservationProperties properties;
|
||||
private final TransactionTemplate transactions;
|
||||
private final TransactionTemplate planningTransactions;
|
||||
private final TransactionTemplate refreshTransactions;
|
||||
|
||||
public ShadowPlanningEngine(SchedulingFacade schedulingFacade, ShadowInputMapper inputMapper,
|
||||
ShadowObservationProperties properties, PlatformTransactionManager transactionManager) {
|
||||
this.schedulingFacade = Objects.requireNonNull(schedulingFacade, "schedulingFacade");
|
||||
this.inputMapper = Objects.requireNonNull(inputMapper, "inputMapper");
|
||||
this.properties = Objects.requireNonNull(properties, "properties");
|
||||
this.transactions = new TransactionTemplate(Objects.requireNonNull(transactionManager, "transactionManager"));
|
||||
this.transactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
this.transactions.setIsolationLevel(TransactionDefinition.ISOLATION_READ_UNCOMMITTED);
|
||||
this.transactions.setTimeout(properties.getTimeoutSeconds());
|
||||
PlatformTransactionManager requiredTransactionManager =
|
||||
Objects.requireNonNull(transactionManager, "transactionManager");
|
||||
this.planningTransactions = transactionTemplate(requiredTransactionManager, properties.getTimeoutSeconds());
|
||||
this.refreshTransactions = transactionTemplate(
|
||||
requiredTransactionManager, properties.getRefreshTimeoutSeconds());
|
||||
}
|
||||
|
||||
public InboundShadowObservation planInbound(String taskNo, String trayNo) {
|
||||
@@ -68,7 +70,7 @@ public class ShadowPlanningEngine {
|
||||
throw new IllegalArgumentException("shadow snapshot instanceId is required");
|
||||
}
|
||||
String warehouseId = configuredWarehouseId();
|
||||
return transactions.execute(status -> {
|
||||
return refreshTransactions.execute(status -> {
|
||||
if (!inputMapper.claimInstance(warehouseId, instanceId, INSTANCE_STALE_SECONDS)) {
|
||||
throw new IllegalStateException("could not claim shadow snapshot refresh for warehouse " + warehouseId);
|
||||
}
|
||||
@@ -215,7 +217,7 @@ public class ShadowPlanningEngine {
|
||||
public void compareInbound(InboundShadowObservation observation, String legacyResultCode,
|
||||
String legacySpaceId) {
|
||||
Objects.requireNonNull(observation, "observation");
|
||||
transactions.executeWithoutResult(status -> {
|
||||
planningTransactions.executeWithoutResult(status -> {
|
||||
validateComparisonSnapshot(observation.snapshotGeneration(), observation.warehouseId(),
|
||||
observation.snapshotSynchronizedAt(), observation.sourceSignature());
|
||||
int inserted = inputMapper.insertComparison(
|
||||
@@ -230,13 +232,13 @@ public class ShadowPlanningEngine {
|
||||
|
||||
public void compareOutbound(OutboundShadowObservation observation, String legacyResultCode) {
|
||||
Objects.requireNonNull(observation, "observation");
|
||||
transactions.executeWithoutResult(status -> {
|
||||
planningTransactions.executeWithoutResult(status -> {
|
||||
validateComparisonSnapshot(observation.snapshotGeneration(), observation.warehouseId(),
|
||||
observation.snapshotSynchronizedAt(), observation.sourceSignature());
|
||||
int inserted = inputMapper.insertComparison(
|
||||
UUID.randomUUID().toString(), observation.warehouseId(), "OUTBOUND_COMPARE",
|
||||
observation.snapshotGeneration(), observation.snapshotSynchronizedAt(),
|
||||
observation.sourceSignature(), "LEGACY_" + nullSafe(legacyResultCode),
|
||||
observation.sourceSignature(), outboundComparisonCode(observation, legacyResultCode),
|
||||
outboundComparisonReason(observation, legacyResultCode));
|
||||
requireComparisonInserted(inserted);
|
||||
});
|
||||
@@ -349,7 +351,7 @@ public class ShadowPlanningEngine {
|
||||
|
||||
private <T> T planWithStableSnapshot(PublishedShadowSnapshot snapshot, Supplier<T> planner) {
|
||||
validateLeasedSnapshot(snapshot);
|
||||
return transactions.execute(status -> {
|
||||
return planningTransactions.execute(status -> {
|
||||
requirePublishedSnapshot(snapshot, "published snapshot changed during planning");
|
||||
T result = planner.get();
|
||||
requirePublishedSnapshot(snapshot, "published snapshot changed during planning");
|
||||
@@ -357,6 +359,15 @@ public class ShadowPlanningEngine {
|
||||
});
|
||||
}
|
||||
|
||||
private TransactionTemplate transactionTemplate(
|
||||
PlatformTransactionManager transactionManager, int timeoutSeconds) {
|
||||
TransactionTemplate template = new TransactionTemplate(transactionManager);
|
||||
template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
template.setIsolationLevel(TransactionDefinition.ISOLATION_READ_UNCOMMITTED);
|
||||
template.setTimeout(timeoutSeconds);
|
||||
return template;
|
||||
}
|
||||
|
||||
private void validateLeasedSnapshot(PublishedShadowSnapshot snapshot) {
|
||||
Objects.requireNonNull(snapshot, "snapshot");
|
||||
if (snapshot.generation() <= 0) {
|
||||
@@ -600,6 +611,15 @@ public class ShadowPlanningEngine {
|
||||
? result + "_CONSERVATIVE" : result;
|
||||
}
|
||||
|
||||
private String outboundComparisonCode(OutboundShadowObservation observation, String legacyCode) {
|
||||
if (observation.observed()
|
||||
&& "ERROR_STOCK_NOT_FOUND".equals(observation.resultCode())
|
||||
&& "3".equals(legacyCode)) {
|
||||
return "MATCH_NO_INVENTORY";
|
||||
}
|
||||
return "LEGACY_" + nullSafe(legacyCode);
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ public class ShadowSafetyGuard implements InitializingBean {
|
||||
if (shadowProperties.getTimeoutSeconds() < 1 || shadowProperties.getTimeoutSeconds() > 30) {
|
||||
throw new IllegalStateException("Shadow observation timeout must be between 1 and 30 seconds");
|
||||
}
|
||||
if (shadowProperties.getRefreshTimeoutSeconds() < 5
|
||||
|| shadowProperties.getRefreshTimeoutSeconds() > 60) {
|
||||
throw new IllegalStateException("Shadow refresh timeout must be between 5 and 60 seconds");
|
||||
}
|
||||
if (shadowProperties.getSnapshotMaxAgeSeconds() < 5
|
||||
|| shadowProperties.getSnapshotMaxAgeSeconds() > 300) {
|
||||
throw new IllegalStateException("Shadow snapshot maximum age must be between 5 and 300 seconds");
|
||||
|
||||
@@ -51,6 +51,11 @@ public class ToWcsServiceImpl implements IToWcsService {
|
||||
mydoJobParam.setInOut("2");
|
||||
List<MydoJob> outMydoJobs = mydoJobService.selectMydoJobListApi(mydoJobParam);
|
||||
for (MydoJob outMydoJob : outMydoJobs) {
|
||||
// HTTP success only confirms receipt. A rear outbound stays in status 1 until
|
||||
// every deeper position in its own lane is physically clear.
|
||||
if (mydoJobService.isOutboundDispatchBlocked(outMydoJob.getJobId())) {
|
||||
continue;
|
||||
}
|
||||
RequestLog requestLog = requestLogService.getRequestLog(PUSH_OUT_MYDO_JOB, JSONUtil.toJsonStr(outMydoJob), "推送出库任务","wms","wcs");
|
||||
if ("1".equals(requestLog.getIsSuccessful())){
|
||||
outMydoJob.setStatus("3");
|
||||
|
||||
@@ -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("单据状态已变化,可能已被处理,请刷新列表");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -95,4 +95,6 @@ public interface MydoJobMapper
|
||||
void updateLedError(HashMap<String, Object> map);
|
||||
|
||||
Integer selectBySpare4OrCargo(InStockinDetail inStockinDetail);
|
||||
|
||||
int countOutboundDispatchBlockers(@Param("jobId") String jobId);
|
||||
}
|
||||
|
||||
@@ -111,4 +111,7 @@ public interface IMydoJobService
|
||||
List<MydoJob> getInJobBySerial(String serial);
|
||||
|
||||
void ykCompleted(MydoJob outJob);
|
||||
|
||||
/** True while a deeper position in the same lane blocks this outbound. */
|
||||
boolean isOutboundDispatchBlocked(String jobId);
|
||||
}
|
||||
|
||||
@@ -540,4 +540,9 @@ public class MydoJobServiceImpl implements IMydoJobService {
|
||||
log.info("移库任务处理成功 jobId:[{}] trayCode:[{}]", outJob.getJobId(), outJob.getTrayCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isOutboundDispatchBlocked(String jobId) {
|
||||
return mydoJobMapper.countOutboundDispatchBlockers(jobId) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,7 @@ fortucky:
|
||||
enabled: ${FORTUCKY_SHADOW_ENABLED:false}
|
||||
warehouse-id: ${FORTUCKY_SHADOW_WAREHOUSE_ID:}
|
||||
timeout-seconds: ${FORTUCKY_SHADOW_TIMEOUT_SECONDS:3}
|
||||
refresh-timeout-seconds: ${FORTUCKY_SHADOW_REFRESH_TIMEOUT_SECONDS:10}
|
||||
snapshot-max-age-seconds: ${FORTUCKY_SHADOW_SNAPSHOT_MAX_AGE_SECONDS:60}
|
||||
quiet-interval-millis: ${FORTUCKY_SHADOW_QUIET_INTERVAL_MILLIS:1000}
|
||||
algorithm:
|
||||
|
||||
@@ -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) >= 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) >= 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) >= 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) >= 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) >= 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) >= 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) >= 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) >= DATEADD(month, -1, SYSDATETIME())
|
||||
</update>
|
||||
</mapper>
|
||||
@@ -271,6 +271,33 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
select * from mydo_job where task_no = #{taskNo}
|
||||
</select>
|
||||
|
||||
<select id="countOutboundDispatchBlockers" parameterType="String" resultType="int">
|
||||
select count(1)
|
||||
from mydo_job job
|
||||
join bas_cargo_space source_space on source_space.cargo_space_id = job.cargo_space_id
|
||||
where job.job_id = #{jobId}
|
||||
and try_convert(int, job.in_or_out) >= 20
|
||||
and exists (
|
||||
select 1
|
||||
from bas_cargo_space outer_space
|
||||
where outer_space.warehouse_id = source_space.warehouse_id
|
||||
and outer_space.group_coding_no = source_space.group_coding_no
|
||||
and try_convert(int, outer_space.group_coding) > try_convert(int, source_space.group_coding)
|
||||
and (
|
||||
isnull(outer_space.status, '') <> '1'
|
||||
or isnull(outer_space.locked, '') <> 'N'
|
||||
or nullif(ltrim(rtrim(outer_space.tray_code)), '') is not null
|
||||
or exists (select 1 from bas_stock stock where stock.cargo_space_id = outer_space.cargo_space_id)
|
||||
or exists (
|
||||
select 1 from mydo_job outer_job
|
||||
where outer_job.job_id <> job.job_id
|
||||
and (outer_job.cargo_space_id = outer_space.cargo_space_id
|
||||
or outer_job.target_cargo_space_id = outer_space.cargo_space_id)
|
||||
)
|
||||
)
|
||||
)
|
||||
</select>
|
||||
|
||||
|
||||
<select id="allotInJobSpace" parameterType="java.util.Map" statementType="CALLABLE">
|
||||
{call proc_allot_space(
|
||||
|
||||
@@ -33,6 +33,7 @@ class ShadowConfigurationTest {
|
||||
assertThat(shadow.isEnabled()).isFalse();
|
||||
assertThat(shadow.getWarehouseId()).isEmpty();
|
||||
assertThat(shadow.getTimeoutSeconds()).isEqualTo(3);
|
||||
assertThat(shadow.getRefreshTimeoutSeconds()).isEqualTo(10);
|
||||
assertThat(shadow.getSnapshotMaxAgeSeconds()).isEqualTo(60);
|
||||
assertThat(shadow.getQuietIntervalMillis()).isEqualTo(1000);
|
||||
assertThat(algorithm.getMode()).isEqualTo(SchedulingMode.SHADOW);
|
||||
|
||||
@@ -211,6 +211,43 @@ class ShadowPlanningEngineTest {
|
||||
eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), eq("LEGACY_10"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundComparisonMatchesWhenBothPlannersReportNoInventory() {
|
||||
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
|
||||
when(mapper.insertComparison(any(), any(), any(), any(Long.class),
|
||||
any(Instant.class), any(), any(), any())).thenReturn(1);
|
||||
ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper);
|
||||
OutboundShadowObservation observation = OutboundShadowObservation.unavailable(
|
||||
SNAPSHOT, "DETAIL-1", "ERROR_STOCK_NOT_FOUND", "no matching inventory");
|
||||
|
||||
engine.compareOutbound(observation, "3");
|
||||
|
||||
verify(mapper).insertComparison(any(), eq("01"), eq("OUTBOUND_COMPARE"), eq(42L),
|
||||
eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), eq("MATCH_NO_INVENTORY"),
|
||||
argThat(reason -> reason.contains("shadow=ERROR_STOCK_NOT_FOUND")
|
||||
&& reason.contains("legacy=3")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundComparisonDoesNotMatchNoInventoryAgainstTechnicalOrDifferentOutcomes() {
|
||||
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
|
||||
when(mapper.insertComparison(any(), any(), any(), any(Long.class),
|
||||
any(Instant.class), any(), any(), any())).thenReturn(1);
|
||||
ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper);
|
||||
OutboundShadowObservation technical = OutboundShadowObservation.unavailable(
|
||||
SNAPSHOT, "DETAIL-1", "ERROR_TECHNICAL", "database failure");
|
||||
OutboundShadowObservation noInventory = OutboundShadowObservation.unavailable(
|
||||
SNAPSHOT, "DETAIL-2", "ERROR_STOCK_NOT_FOUND", "no matching inventory");
|
||||
|
||||
engine.compareOutbound(technical, "3");
|
||||
engine.compareOutbound(noInventory, "10");
|
||||
|
||||
verify(mapper).insertComparison(any(), eq("01"), eq("OUTBOUND_COMPARE"), eq(42L),
|
||||
eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), eq("LEGACY_3"), any());
|
||||
verify(mapper).insertComparison(any(), eq("01"), eq("OUTBOUND_COMPARE"), eq(42L),
|
||||
eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), eq("LEGACY_10"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void inboundComparisonRetainsRequiredEvidenceWhenUnicodeReasonIsOversized() {
|
||||
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
|
||||
@@ -278,17 +315,23 @@ class ShadowPlanningEngineTest {
|
||||
|
||||
@Test
|
||||
void refreshClaimsInstanceAndReturnsValidatedPublishedSnapshot() {
|
||||
ShadowObservationProperties properties = properties();
|
||||
properties.setTimeoutSeconds(3);
|
||||
properties.setRefreshTimeoutSeconds(11);
|
||||
CapturingTransactionManager transactions = new CapturingTransactionManager();
|
||||
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
|
||||
when(mapper.claimInstance("01", "INSTANCE-1", 120)).thenReturn(true);
|
||||
PublishedShadowSnapshot refreshedSnapshot = snapshot(43L, "01");
|
||||
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(refreshedSnapshot));
|
||||
ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper);
|
||||
ShadowPlanningEngine engine = new ShadowPlanningEngine(
|
||||
mock(SchedulingFacade.class), mapper, properties, transactions);
|
||||
|
||||
PublishedShadowSnapshot refreshed = engine.refreshSnapshot(43L, "INSTANCE-1");
|
||||
|
||||
verify(mapper).claimInstance("01", "INSTANCE-1", 120);
|
||||
verify(mapper).refreshSnapshot("01", 43L, "INSTANCE-1");
|
||||
assertThat(refreshed).isEqualTo(refreshedSnapshot);
|
||||
assertThat(transactions.definition.getTimeout()).isEqualTo(11);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -76,6 +76,22 @@ class ShadowSafetyGuardTest {
|
||||
.hasMessageContaining("between 100 and 10000");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRefreshTimeoutOutsideValidatedRange() {
|
||||
ShadowObservationProperties shadow = enabledShadow();
|
||||
AlgorithmProperties algorithm = new AlgorithmProperties();
|
||||
algorithm.setMode(SchedulingMode.SHADOW);
|
||||
algorithm.setDatabaseWritesEnabled(true);
|
||||
|
||||
shadow.setRefreshTimeoutSeconds(3);
|
||||
assertThatThrownBy(() -> new ShadowSafetyGuard(shadow, algorithm).validate())
|
||||
.hasMessageContaining("refresh timeout");
|
||||
|
||||
shadow.setRefreshTimeoutSeconds(61);
|
||||
assertThatThrownBy(() -> new ShadowSafetyGuard(shadow, algorithm).validate())
|
||||
.hasMessageContaining("refresh timeout");
|
||||
}
|
||||
|
||||
private ShadowObservationProperties enabledShadow() {
|
||||
ShadowObservationProperties properties = new ShadowObservationProperties();
|
||||
properties.setEnabled(true);
|
||||
|
||||
@@ -31,6 +31,23 @@ class ShadowV3OperationsPackageTest {
|
||||
.isRegularFile();
|
||||
}
|
||||
|
||||
@Test
|
||||
void erpPushExceptionMenuUsesIdentityGeneratedKeys() throws IOException {
|
||||
String script = text("sql/upgrade-erp-push-exception-menu.sql");
|
||||
|
||||
assertThat(script).contains("SCOPE_IDENTITY()");
|
||||
assertThat(script).doesNotContain("MAX(menu_id)", "(menu_id, menu_name");
|
||||
}
|
||||
|
||||
@Test
|
||||
void erpPushExceptionMenuIsAsciiSafeForSqlcmd13() throws IOException {
|
||||
byte[] script = Files.readAllBytes(root.resolve("sql/upgrade-erp-push-exception-menu.sql"));
|
||||
|
||||
for (byte value : script) {
|
||||
assertThat(value & 0x80).as("SQL script byte must be ASCII").isZero();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void preflightIsReadOnlyAndChecksExactSourceAndSingleProcess() throws IOException {
|
||||
String script = text("scripts/shadow-v3-preflight.cmd");
|
||||
@@ -292,6 +309,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 {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.ruoyi.api.service.impl;
|
||||
|
||||
import com.ruoyi.job.mydojob.domain.MydoJob;
|
||||
import com.ruoyi.job.mydojob.service.IMydoJobService;
|
||||
import com.ruoyi.log.domain.RequestLog;
|
||||
import com.ruoyi.log.service.IRequestLogService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ToWcsServiceImplTest {
|
||||
|
||||
@Test
|
||||
void blockedRearOutboundWaitsWithoutCallingWcsOrAdvancingStatus() {
|
||||
IRequestLogService requests = mock(IRequestLogService.class);
|
||||
IMydoJobService jobs = mock(IMydoJobService.class);
|
||||
MydoJob rear = outbound("OUT-REAR", "120803");
|
||||
when(jobs.selectMydoJobListApi(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(rear));
|
||||
when(jobs.isOutboundDispatchBlocked("OUT-REAR")).thenReturn(true);
|
||||
ToWcsServiceImpl service = service(requests, jobs);
|
||||
|
||||
service.pushOutMydoJob();
|
||||
|
||||
verify(requests, never()).getRequestLog(anyString(), anyString(), anyString(), anyString(), anyString());
|
||||
verify(jobs, never()).updateMydoJob(rear);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unblockedOutboundDispatchesAndAdvancesExactlyOnce() {
|
||||
IRequestLogService requests = mock(IRequestLogService.class);
|
||||
IMydoJobService jobs = mock(IMydoJobService.class);
|
||||
MydoJob front = outbound("OUT-FRONT", "120703");
|
||||
when(jobs.selectMydoJobListApi(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(front));
|
||||
when(jobs.isOutboundDispatchBlocked("OUT-FRONT")).thenReturn(false);
|
||||
RequestLog accepted = new RequestLog();
|
||||
accepted.setIsSuccessful("1");
|
||||
when(requests.getRequestLog(eq(ToWcsServiceImpl.PUSH_OUT_MYDO_JOB), anyString(),
|
||||
eq("推送出库任务"), eq("wms"), eq("wcs"))).thenReturn(accepted);
|
||||
ToWcsServiceImpl service = service(requests, jobs);
|
||||
|
||||
service.pushOutMydoJob();
|
||||
|
||||
verify(requests).getRequestLog(eq(ToWcsServiceImpl.PUSH_OUT_MYDO_JOB), anyString(),
|
||||
eq("推送出库任务"), eq("wms"), eq("wcs"));
|
||||
verify(jobs).updateMydoJob(front);
|
||||
org.assertj.core.api.Assertions.assertThat(front.getStatus()).isEqualTo("3");
|
||||
}
|
||||
|
||||
private ToWcsServiceImpl service(IRequestLogService requests, IMydoJobService jobs) {
|
||||
ToWcsServiceImpl service = new ToWcsServiceImpl();
|
||||
ReflectionTestUtils.setField(service, "requestLogService", requests);
|
||||
ReflectionTestUtils.setField(service, "mydoJobService", jobs);
|
||||
return service;
|
||||
}
|
||||
|
||||
private MydoJob outbound(String jobId, String spaceId) {
|
||||
MydoJob job = new MydoJob();
|
||||
job.setJobId(jobId);
|
||||
job.setCargoSpaceId(spaceId);
|
||||
job.setInOrOut("21");
|
||||
job.setStatus("1");
|
||||
return job;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
17
ruoyi-ui/src/api/erp/pushException.js
Normal file
17
ruoyi-ui/src/api/erp/pushException.js
Normal 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
|
||||
})
|
||||
}
|
||||
224
ruoyi-ui/src/views/erp/pushException/index.vue
Normal file
224
ruoyi-ui/src/views/erp/pushException/index.vue
Normal 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>
|
||||
44
scripts/dispatch-guard/test-dispatch-guard.ps1
Normal file
44
scripts/dispatch-guard/test-dispatch-guard.ps1
Normal file
@@ -0,0 +1,44 @@
|
||||
param(
|
||||
[string]$Server = 'lpc:.',
|
||||
[string]$MapperFile = "$PSScriptRoot/../../ruoyi-admin/src/main/resources/mapper/job/MydoJobMapper.xml"
|
||||
)
|
||||
$ErrorActionPreference='Stop'
|
||||
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
|
||||
$testDb='FT546WMS_DispatchGuard_Test_'+[Guid]::NewGuid().ToString('N')
|
||||
$cn=[Data.SqlClient.SqlConnection]::new("Server=$Server;Database=master;Integrated Security=True;Connect Timeout=8;Application Name=FT546DispatchGuardTests")
|
||||
function Exec([string]$sql){$c=$cn.CreateCommand();$c.CommandTimeout=15;$c.CommandText=$sql;try{[void]$c.ExecuteNonQuery()}finally{$c.Dispose()}}
|
||||
function Scalar([string]$sql,[string]$jobId=$null){$c=$cn.CreateCommand();$c.CommandTimeout=15;$c.CommandText=$sql;if($null-ne$jobId){[void]$c.Parameters.Add('@jobId',[Data.SqlDbType]::VarChar,255);$c.Parameters['@jobId'].Value=$jobId};try{$c.ExecuteScalar()}finally{$c.Dispose()}}
|
||||
function Assert([bool]$ok,[string]$name){if(-not$ok){throw "FAIL: $name"};Write-Output "PASS: $name"}
|
||||
$created=$false
|
||||
$cn.Open()
|
||||
try{
|
||||
$data=[string](Scalar "SELECT CONVERT(nvarchar(4000),SERVERPROPERTY('InstanceDefaultDataPath'))")
|
||||
$log=[string](Scalar "SELECT CONVERT(nvarchar(4000),SERVERPROPERTY('InstanceDefaultLogPath'))")
|
||||
Exec "CREATE DATABASE [$testDb] ON PRIMARY(NAME=N'$testDb',FILENAME=N'$data$testDb.mdf',SIZE=8MB,MAXSIZE=64MB) LOG ON(NAME=N'${testDb}_log',FILENAME=N'$log${testDb}_log.ldf',SIZE=8MB,MAXSIZE=64MB)"
|
||||
$created=$true;$cn.ChangeDatabase($testDb);Exec 'SET LOCK_TIMEOUT 1500;SET DEADLOCK_PRIORITY LOW;'
|
||||
Exec @'
|
||||
CREATE TABLE bas_cargo_space(cargo_space_id varchar(80),warehouse_id varchar(80),group_coding_no varchar(80),group_coding varchar(20),status varchar(10),locked varchar(10),tray_code varchar(80));
|
||||
CREATE TABLE bas_stock(stock_id varchar(80),cargo_space_id varchar(80));
|
||||
CREATE TABLE mydo_job(job_id varchar(255),cargo_space_id varchar(80),target_cargo_space_id varchar(80),in_or_out varchar(10));
|
||||
INSERT bas_cargo_space VALUES
|
||||
('REAR','01','LANE-A','1','4','N','REAR-TRAY'),('FRONT','01','LANE-A','2','2','N','FRONT-TRAY'),
|
||||
('OTHER-REAR','01','LANE-B','1','4','N','OTHER'),('OTHER-FRONT','01','LANE-B','2','1','N',NULL);
|
||||
INSERT bas_stock VALUES('FRONT-STOCK','FRONT');
|
||||
INSERT mydo_job VALUES('OUT-REAR','REAR',NULL,'21'),('OUT-FRONT','FRONT',NULL,'21'),('OUT-OTHER','OTHER-REAR',NULL,'21');
|
||||
'@
|
||||
$settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Ignore
|
||||
$reader=[Xml.XmlReader]::Create($MapperFile,$settings);try{$xml=[Xml.XmlDocument]::new();$xml.Load($reader)}finally{$reader.Dispose()}
|
||||
$node=$xml.SelectSingleNode("//*[local-name()='select' and @id='countOutboundDispatchBlockers']")
|
||||
if($null-eq$node){throw 'Mapper query missing'}
|
||||
$sql=$node.InnerText.Replace('#{jobId}','@jobId')
|
||||
Assert ((Scalar $sql 'OUT-REAR')-gt 0) 'occupied outer position blocks rear outbound'
|
||||
Assert ((Scalar $sql 'OUT-FRONT')-eq 0) 'front outbound is not blocked by its own lane'
|
||||
Assert ((Scalar $sql 'OUT-OTHER')-eq 0) 'unrelated lane remains dispatchable'
|
||||
Exec "DELETE bas_stock;DELETE mydo_job WHERE job_id='OUT-FRONT';UPDATE bas_cargo_space SET status='1',tray_code=NULL WHERE cargo_space_id='FRONT';INSERT mydo_job VALUES('MOVE-FRONT','FRONT','OTHER-FRONT','30')"
|
||||
Assert ((Scalar $sql 'OUT-REAR')-gt 0) 'unfinished relocation keeps rear outbound waiting'
|
||||
Exec "DELETE mydo_job WHERE job_id='MOVE-FRONT'"
|
||||
Assert ((Scalar $sql 'OUT-REAR')-eq 0) 'confirmed relocation completion releases rear outbound'
|
||||
}finally{
|
||||
if($created){$cn.ChangeDatabase('master');[Data.SqlClient.SqlConnection]::ClearAllPools();if($testDb-notmatch'^FT546WMS_DispatchGuard_Test_[a-f0-9]{32}$'){throw'Invalid scratch database'};Exec "DROP DATABASE [$testDb]";Write-Output "Scratch database removed: $testDb"}
|
||||
$cn.Dispose()
|
||||
}
|
||||
91
sql/upgrade-erp-push-exception-menu.sql
Normal file
91
sql/upgrade-erp-push-exception-menu.sql
Normal file
@@ -0,0 +1,91 @@
|
||||
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;
|
||||
DECLARE @lock_error nvarchar(2048) = CONVERT(nvarchar(2048), 0xE065D56CB783D65320004500520050002000A8630190025F385EDC8355534753A77E0195);
|
||||
DECLARE @menu_name nvarchar(255) = CONVERT(nvarchar(255), 0x450052005000A8630190025F385E);
|
||||
DECLARE @menu_remark nvarchar(500) = CONVERT(nvarchar(500), 0x450052005000DE56204F3159258D17526888);
|
||||
DECLARE @retry_name nvarchar(255) = CONVERT(nvarchar(255), 0x450052005000025F385ECD91A863);
|
||||
DECLARE @retry_remark nvarchar(500) = CONVERT(nvarchar(500), 0x3159258D55536E63CD91B065A0526551450052005000DE56204F1F961752);
|
||||
|
||||
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, @lock_error, 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
|
||||
INSERT INTO dbo.sys_menu
|
||||
(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_name, 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, @menu_remark);
|
||||
|
||||
SET @menu_id = CONVERT(bigint, SCOPE_IDENTITY());
|
||||
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
|
||||
INSERT INTO dbo.sys_menu
|
||||
(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_name, @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, @retry_remark);
|
||||
|
||||
SET @retry_id = CONVERT(bigint, SCOPE_IDENTITY());
|
||||
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;
|
||||
Reference in New Issue
Block a user