6 Commits

36 changed files with 1608 additions and 13 deletions

View 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.

View File

@@ -756,7 +756,7 @@ BEGIN
COUNT(*) AS space_count, COUNT(*) AS space_count,
SUM(CASE WHEN cargo.space_status = '2' OR ISNULL(stock.stock_detail_count, 0) > 0 THEN 1 ELSE 0 END) AS occupied_count, SUM(CASE WHEN cargo.space_status = '2' OR ISNULL(stock.stock_detail_count, 0) > 0 THEN 1 ELSE 0 END) AS occupied_count,
SUM(CASE WHEN cargo.space_status = '1' SUM(CASE WHEN cargo.space_status = '1'
AND NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL AND (NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL OR LTRIM(RTRIM(cargo.locked)) IN ('0', 'N'))
AND NULLIF(LTRIM(RTRIM(cargo.tray_code)), '') IS NULL AND NULLIF(LTRIM(RTRIM(cargo.tray_code)), '') IS NULL
AND ISNULL(stock.stock_detail_count, 0) = 0 AND ISNULL(stock.stock_detail_count, 0) = 0
AND ISNULL(role.role, 'STORAGE') = 'STORAGE' AND ISNULL(role.role, 'STORAGE') = 'STORAGE'

View File

@@ -775,7 +775,7 @@ BEGIN
SELECT cargo.warehouse_id, cargo.lane_code, COUNT(*) AS space_count, SELECT cargo.warehouse_id, cargo.lane_code, COUNT(*) AS space_count,
SUM(CASE WHEN cargo.space_status = '2' OR ISNULL(stock.stock_detail_count, 0) > 0 THEN 1 ELSE 0 END) AS occupied_count, SUM(CASE WHEN cargo.space_status = '2' OR ISNULL(stock.stock_detail_count, 0) > 0 THEN 1 ELSE 0 END) AS occupied_count,
SUM(CASE WHEN cargo.space_status = '1' SUM(CASE WHEN cargo.space_status = '1'
AND NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL AND (NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL OR LTRIM(RTRIM(cargo.locked)) IN ('0', 'N'))
AND NULLIF(LTRIM(RTRIM(cargo.tray_code)), '') IS NULL AND NULLIF(LTRIM(RTRIM(cargo.tray_code)), '') IS NULL
AND ISNULL(stock.stock_detail_count, 0) = 0 AND ISNULL(stock.stock_detail_count, 0) = 0
AND ISNULL(role.role, 'STORAGE') = 'STORAGE' AND ISNULL(role.role, 'STORAGE') = 'STORAGE'

View File

@@ -9,6 +9,7 @@ public class ShadowObservationProperties {
private boolean enabled; private boolean enabled;
private String warehouseId = ""; private String warehouseId = "";
private int timeoutSeconds = 3; private int timeoutSeconds = 3;
private int refreshTimeoutSeconds = 10;
private int snapshotMaxAgeSeconds = 60; private int snapshotMaxAgeSeconds = 60;
private int quietIntervalMillis = 1000; private int quietIntervalMillis = 1000;
@@ -36,6 +37,14 @@ public class ShadowObservationProperties {
this.timeoutSeconds = timeoutSeconds; this.timeoutSeconds = timeoutSeconds;
} }
public int getRefreshTimeoutSeconds() {
return refreshTimeoutSeconds;
}
public void setRefreshTimeoutSeconds(int refreshTimeoutSeconds) {
this.refreshTimeoutSeconds = refreshTimeoutSeconds;
}
public int getSnapshotMaxAgeSeconds() { public int getSnapshotMaxAgeSeconds() {
return snapshotMaxAgeSeconds; return snapshotMaxAgeSeconds;
} }

View File

@@ -35,17 +35,19 @@ public class ShadowPlanningEngine {
private final SchedulingFacade schedulingFacade; private final SchedulingFacade schedulingFacade;
private final ShadowInputMapper inputMapper; private final ShadowInputMapper inputMapper;
private final ShadowObservationProperties properties; private final ShadowObservationProperties properties;
private final TransactionTemplate transactions; private final TransactionTemplate planningTransactions;
private final TransactionTemplate refreshTransactions;
public ShadowPlanningEngine(SchedulingFacade schedulingFacade, ShadowInputMapper inputMapper, public ShadowPlanningEngine(SchedulingFacade schedulingFacade, ShadowInputMapper inputMapper,
ShadowObservationProperties properties, PlatformTransactionManager transactionManager) { ShadowObservationProperties properties, PlatformTransactionManager transactionManager) {
this.schedulingFacade = Objects.requireNonNull(schedulingFacade, "schedulingFacade"); this.schedulingFacade = Objects.requireNonNull(schedulingFacade, "schedulingFacade");
this.inputMapper = Objects.requireNonNull(inputMapper, "inputMapper"); this.inputMapper = Objects.requireNonNull(inputMapper, "inputMapper");
this.properties = Objects.requireNonNull(properties, "properties"); this.properties = Objects.requireNonNull(properties, "properties");
this.transactions = new TransactionTemplate(Objects.requireNonNull(transactionManager, "transactionManager")); PlatformTransactionManager requiredTransactionManager =
this.transactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); Objects.requireNonNull(transactionManager, "transactionManager");
this.transactions.setIsolationLevel(TransactionDefinition.ISOLATION_READ_UNCOMMITTED); this.planningTransactions = transactionTemplate(requiredTransactionManager, properties.getTimeoutSeconds());
this.transactions.setTimeout(properties.getTimeoutSeconds()); this.refreshTransactions = transactionTemplate(
requiredTransactionManager, properties.getRefreshTimeoutSeconds());
} }
public InboundShadowObservation planInbound(String taskNo, String trayNo) { public InboundShadowObservation planInbound(String taskNo, String trayNo) {
@@ -68,7 +70,7 @@ public class ShadowPlanningEngine {
throw new IllegalArgumentException("shadow snapshot instanceId is required"); throw new IllegalArgumentException("shadow snapshot instanceId is required");
} }
String warehouseId = configuredWarehouseId(); String warehouseId = configuredWarehouseId();
return transactions.execute(status -> { return refreshTransactions.execute(status -> {
if (!inputMapper.claimInstance(warehouseId, instanceId, INSTANCE_STALE_SECONDS)) { if (!inputMapper.claimInstance(warehouseId, instanceId, INSTANCE_STALE_SECONDS)) {
throw new IllegalStateException("could not claim shadow snapshot refresh for warehouse " + warehouseId); 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, public void compareInbound(InboundShadowObservation observation, String legacyResultCode,
String legacySpaceId) { String legacySpaceId) {
Objects.requireNonNull(observation, "observation"); Objects.requireNonNull(observation, "observation");
transactions.executeWithoutResult(status -> { planningTransactions.executeWithoutResult(status -> {
validateComparisonSnapshot(observation.snapshotGeneration(), observation.warehouseId(), validateComparisonSnapshot(observation.snapshotGeneration(), observation.warehouseId(),
observation.snapshotSynchronizedAt(), observation.sourceSignature()); observation.snapshotSynchronizedAt(), observation.sourceSignature());
int inserted = inputMapper.insertComparison( int inserted = inputMapper.insertComparison(
@@ -230,13 +232,13 @@ public class ShadowPlanningEngine {
public void compareOutbound(OutboundShadowObservation observation, String legacyResultCode) { public void compareOutbound(OutboundShadowObservation observation, String legacyResultCode) {
Objects.requireNonNull(observation, "observation"); Objects.requireNonNull(observation, "observation");
transactions.executeWithoutResult(status -> { planningTransactions.executeWithoutResult(status -> {
validateComparisonSnapshot(observation.snapshotGeneration(), observation.warehouseId(), validateComparisonSnapshot(observation.snapshotGeneration(), observation.warehouseId(),
observation.snapshotSynchronizedAt(), observation.sourceSignature()); observation.snapshotSynchronizedAt(), observation.sourceSignature());
int inserted = inputMapper.insertComparison( int inserted = inputMapper.insertComparison(
UUID.randomUUID().toString(), observation.warehouseId(), "OUTBOUND_COMPARE", UUID.randomUUID().toString(), observation.warehouseId(), "OUTBOUND_COMPARE",
observation.snapshotGeneration(), observation.snapshotSynchronizedAt(), observation.snapshotGeneration(), observation.snapshotSynchronizedAt(),
observation.sourceSignature(), "LEGACY_" + nullSafe(legacyResultCode), observation.sourceSignature(), outboundComparisonCode(observation, legacyResultCode),
outboundComparisonReason(observation, legacyResultCode)); outboundComparisonReason(observation, legacyResultCode));
requireComparisonInserted(inserted); requireComparisonInserted(inserted);
}); });
@@ -349,7 +351,7 @@ public class ShadowPlanningEngine {
private <T> T planWithStableSnapshot(PublishedShadowSnapshot snapshot, Supplier<T> planner) { private <T> T planWithStableSnapshot(PublishedShadowSnapshot snapshot, Supplier<T> planner) {
validateLeasedSnapshot(snapshot); validateLeasedSnapshot(snapshot);
return transactions.execute(status -> { return planningTransactions.execute(status -> {
requirePublishedSnapshot(snapshot, "published snapshot changed during planning"); requirePublishedSnapshot(snapshot, "published snapshot changed during planning");
T result = planner.get(); T result = planner.get();
requirePublishedSnapshot(snapshot, "published snapshot changed during planning"); 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) { private void validateLeasedSnapshot(PublishedShadowSnapshot snapshot) {
Objects.requireNonNull(snapshot, "snapshot"); Objects.requireNonNull(snapshot, "snapshot");
if (snapshot.generation() <= 0) { if (snapshot.generation() <= 0) {
@@ -600,6 +611,15 @@ public class ShadowPlanningEngine {
? result + "_CONSERVATIVE" : result; ? 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) { private boolean isBlank(String value) {
return value == null || value.isBlank(); return value == null || value.isBlank();
} }

View File

@@ -36,6 +36,10 @@ public class ShadowSafetyGuard implements InitializingBean {
if (shadowProperties.getTimeoutSeconds() < 1 || shadowProperties.getTimeoutSeconds() > 30) { if (shadowProperties.getTimeoutSeconds() < 1 || shadowProperties.getTimeoutSeconds() > 30) {
throw new IllegalStateException("Shadow observation timeout must be between 1 and 30 seconds"); 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 if (shadowProperties.getSnapshotMaxAgeSeconds() < 5
|| shadowProperties.getSnapshotMaxAgeSeconds() > 300) { || shadowProperties.getSnapshotMaxAgeSeconds() > 300) {
throw new IllegalStateException("Shadow snapshot maximum age must be between 5 and 300 seconds"); throw new IllegalStateException("Shadow snapshot maximum age must be between 5 and 300 seconds");

View File

@@ -51,6 +51,11 @@ public class ToWcsServiceImpl implements IToWcsService {
mydoJobParam.setInOut("2"); mydoJobParam.setInOut("2");
List<MydoJob> outMydoJobs = mydoJobService.selectMydoJobListApi(mydoJobParam); List<MydoJob> outMydoJobs = mydoJobService.selectMydoJobListApi(mydoJobParam);
for (MydoJob outMydoJob : outMydoJobs) { 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"); RequestLog requestLog = requestLogService.getRequestLog(PUSH_OUT_MYDO_JOB, JSONUtil.toJsonStr(outMydoJob), "推送出库任务","wms","wcs");
if ("1".equals(requestLog.getIsSuccessful())){ if ("1".equals(requestLog.getIsSuccessful())){
outMydoJob.setStatus("3"); outMydoJob.setStatus("3");

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

@@ -95,4 +95,6 @@ public interface MydoJobMapper
void updateLedError(HashMap<String, Object> map); void updateLedError(HashMap<String, Object> map);
Integer selectBySpare4OrCargo(InStockinDetail inStockinDetail); Integer selectBySpare4OrCargo(InStockinDetail inStockinDetail);
int countOutboundDispatchBlockers(@Param("jobId") String jobId);
} }

View File

@@ -111,4 +111,7 @@ public interface IMydoJobService
List<MydoJob> getInJobBySerial(String serial); List<MydoJob> getInJobBySerial(String serial);
void ykCompleted(MydoJob outJob); void ykCompleted(MydoJob outJob);
/** True while a deeper position in the same lane blocks this outbound. */
boolean isOutboundDispatchBlocked(String jobId);
} }

View File

@@ -540,4 +540,9 @@ public class MydoJobServiceImpl implements IMydoJobService {
log.info("移库任务处理成功 jobId:[{}] trayCode:[{}]", outJob.getJobId(), outJob.getTrayCode()); log.info("移库任务处理成功 jobId:[{}] trayCode:[{}]", outJob.getJobId(), outJob.getTrayCode());
} }
} }
@Override
public boolean isOutboundDispatchBlocked(String jobId) {
return mydoJobMapper.countOutboundDispatchBlockers(jobId) > 0;
}
} }

View File

@@ -189,6 +189,7 @@ fortucky:
enabled: ${FORTUCKY_SHADOW_ENABLED:false} enabled: ${FORTUCKY_SHADOW_ENABLED:false}
warehouse-id: ${FORTUCKY_SHADOW_WAREHOUSE_ID:} warehouse-id: ${FORTUCKY_SHADOW_WAREHOUSE_ID:}
timeout-seconds: ${FORTUCKY_SHADOW_TIMEOUT_SECONDS:3} 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} snapshot-max-age-seconds: ${FORTUCKY_SHADOW_SNAPSHOT_MAX_AGE_SECONDS:60}
quiet-interval-millis: ${FORTUCKY_SHADOW_QUIET_INTERVAL_MILLIS:1000} quiet-interval-millis: ${FORTUCKY_SHADOW_QUIET_INTERVAL_MILLIS:1000}
algorithm: algorithm:

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

@@ -271,6 +271,33 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
select * from mydo_job where task_no = #{taskNo} select * from mydo_job where task_no = #{taskNo}
</select> </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) &gt;= 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) &gt; try_convert(int, source_space.group_coding)
and (
isnull(outer_space.status, '') &lt;&gt; '1'
or isnull(outer_space.locked, '') &lt;&gt; '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 &lt;&gt; 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"> <select id="allotInJobSpace" parameterType="java.util.Map" statementType="CALLABLE">
{call proc_allot_space( {call proc_allot_space(

View File

@@ -33,6 +33,7 @@ class ShadowConfigurationTest {
assertThat(shadow.isEnabled()).isFalse(); assertThat(shadow.isEnabled()).isFalse();
assertThat(shadow.getWarehouseId()).isEmpty(); assertThat(shadow.getWarehouseId()).isEmpty();
assertThat(shadow.getTimeoutSeconds()).isEqualTo(3); assertThat(shadow.getTimeoutSeconds()).isEqualTo(3);
assertThat(shadow.getRefreshTimeoutSeconds()).isEqualTo(10);
assertThat(shadow.getSnapshotMaxAgeSeconds()).isEqualTo(60); assertThat(shadow.getSnapshotMaxAgeSeconds()).isEqualTo(60);
assertThat(shadow.getQuietIntervalMillis()).isEqualTo(1000); assertThat(shadow.getQuietIntervalMillis()).isEqualTo(1000);
assertThat(algorithm.getMode()).isEqualTo(SchedulingMode.SHADOW); assertThat(algorithm.getMode()).isEqualTo(SchedulingMode.SHADOW);

View File

@@ -211,6 +211,43 @@ class ShadowPlanningEngineTest {
eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), eq("LEGACY_10"), any()); 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 @Test
void inboundComparisonRetainsRequiredEvidenceWhenUnicodeReasonIsOversized() { void inboundComparisonRetainsRequiredEvidenceWhenUnicodeReasonIsOversized() {
ShadowInputMapper mapper = mock(ShadowInputMapper.class); ShadowInputMapper mapper = mock(ShadowInputMapper.class);
@@ -278,17 +315,23 @@ class ShadowPlanningEngineTest {
@Test @Test
void refreshClaimsInstanceAndReturnsValidatedPublishedSnapshot() { void refreshClaimsInstanceAndReturnsValidatedPublishedSnapshot() {
ShadowObservationProperties properties = properties();
properties.setTimeoutSeconds(3);
properties.setRefreshTimeoutSeconds(11);
CapturingTransactionManager transactions = new CapturingTransactionManager();
ShadowInputMapper mapper = mock(ShadowInputMapper.class); ShadowInputMapper mapper = mock(ShadowInputMapper.class);
when(mapper.claimInstance("01", "INSTANCE-1", 120)).thenReturn(true); when(mapper.claimInstance("01", "INSTANCE-1", 120)).thenReturn(true);
PublishedShadowSnapshot refreshedSnapshot = snapshot(43L, "01"); PublishedShadowSnapshot refreshedSnapshot = snapshot(43L, "01");
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(refreshedSnapshot)); 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"); PublishedShadowSnapshot refreshed = engine.refreshSnapshot(43L, "INSTANCE-1");
verify(mapper).claimInstance("01", "INSTANCE-1", 120); verify(mapper).claimInstance("01", "INSTANCE-1", 120);
verify(mapper).refreshSnapshot("01", 43L, "INSTANCE-1"); verify(mapper).refreshSnapshot("01", 43L, "INSTANCE-1");
assertThat(refreshed).isEqualTo(refreshedSnapshot); assertThat(refreshed).isEqualTo(refreshedSnapshot);
assertThat(transactions.definition.getTimeout()).isEqualTo(11);
} }
@Test @Test

View File

@@ -76,6 +76,22 @@ class ShadowSafetyGuardTest {
.hasMessageContaining("between 100 and 10000"); .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() { private ShadowObservationProperties enabledShadow() {
ShadowObservationProperties properties = new ShadowObservationProperties(); ShadowObservationProperties properties = new ShadowObservationProperties();
properties.setEnabled(true); properties.setEnabled(true);

View File

@@ -31,6 +31,23 @@ class ShadowV3OperationsPackageTest {
.isRegularFile(); .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 @Test
void preflightIsReadOnlyAndChecksExactSourceAndSingleProcess() throws IOException { void preflightIsReadOnlyAndChecksExactSourceAndSingleProcess() throws IOException {
String script = text("scripts/shadow-v3-preflight.cmd"); String script = text("scripts/shadow-v3-preflight.cmd");
@@ -292,6 +309,10 @@ class ShadowV3OperationsPackageTest {
CmdResult result = runProcess(new ProcessBuilder( CmdResult result = runProcess(new ProcessBuilder(
"git", "clone", "--quiet", root.toString(), destination.toString()), 60); "git", "clone", "--quiet", root.toString(), destination.toString()), 60);
assertThat(result.exitCode()).as(result.output()).isZero(); 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 { private String gitHead(Path repository) throws Exception {

View File

@@ -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;
}
}

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,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()
}

View File

@@ -0,0 +1,57 @@
# 影子通道汇总锁定值修复(2026-09-14)
现场 `locked=N` 表示未锁定;旧 `alg.usp_rebuild_lane_state` 仅接受 NULL/空白,导致通道可用数量归零。补丁让它与现有货位视图一致,接受 NULL、空白、`0`、`N`。其他占用、库存、角色、预留、出库与空洞限制全部保留。两个完整安装 SQL 也同步修正,避免以后安装重新引入问题。
## 对正在运行的业务有何影响
影子读取生产 `dbo` 数据,生成 `alg.*` 快照,再规划和记录比较结果。此次只 ALTER `alg.usp_rebuild_lane_state`,其运行结果写入 `alg.lane_state`。不修改生产货位的锁定值、货位状态、库存、订单或 Legacy 分配过程,不切换 ACTIVE,不需要替换 JAR 或重启 WMS/WCS。
现有三通道混放试点是另外一套已批准的业务规则,本补丁不更改其范围或开关。共用 SQL Server 仍会共享 CPU、I/O、内存及锁资源,不能承诺性能影响为零;补丁使用 1.5 秒锁等待上限,等待失败退出,不结束其他会话。
## 从 CMD 操作
在本目录执行,使用 Windows 集成认证;账号需要读取过程定义及 ALTER 此过程的权限。默认数据库仅支持 `FT546-wms`。
```bat
repair-shadow-lane-lock.cmd -Mode Check
```
记录返回的 `sha256`。确认状态为 `NEEDS_FIX` 后,将下面的占位文字换成该值;备份文件使用全新路径(不能覆盖已有文件):
```bat
repair-shadow-lane-lock.cmd -Mode Apply -ExpectedSha256 检查返回的sha256 -BackupFile "C:\project\FT546WMS-shadow-lane-lock-20260914\original-procedure.json"
```
返回 `status=OK` 后,等待现有影子流程在业务退出后的安静窗口正常刷新;它不是固定每隔几分钟必刷。不要手动重建正在使用的快照,也不要为此运行完整 `shadow-v3-install.cmd`。补丁不改整包版本标识,用过程哈希确认状态。`ALREADY_FIXED` 表示无需重复更新;hash mismatch 表示现场定义有变化,应先核对。锁超时或失败时事务自动回滚,已有备份保留;重试采用新的备份文件名。
只读检查(SQL 查询窗口):
```sql
SELECT snapshot_generation,snapshot_status,synchronized_at,last_error
FROM alg.shadow_snapshot_state WHERE warehouse_id='01';
SELECT COUNT(*) candidate_lanes,SUM(l.available_count) aggregated_available_spaces
FROM alg.lane_state l JOIN alg.shadow_snapshot_state p
ON p.warehouse_id=l.warehouse_id AND p.snapshot_generation=l.snapshot_generation
WHERE l.warehouse_id='01' AND p.snapshot_status='PUBLISHED'
AND l.available_count>0 AND l.pending_outbound=0 AND l.has_gap=0;
```
候选通道恢复不代表任意托盘一定能分配,也不代表完整算法可以接管。仍需新发生的真实入库/出库比较和混放移库闭环证据。
## 回退
重新 `Check`,将返回的当前 `sha256` 填入命令,使用 Apply 保存的原始备份。只恢复此过程原文及 ANSI/quoted 设置;有后续改动时会拒绝覆盖:
```bat
repair-shadow-lane-lock.cmd -Mode Rollback -ExpectedSha256 当前sha256 -BackupFile "C:\project\FT546WMS-shadow-lane-lock-20260914\original-procedure.json"
```
回退后下一次正常刷新会恢复旧汇总行为,包括旧的 `N` 误排除问题。既有影子记录不删除,业务数据不回退。
## 隔离回归
```bat
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts\shadow-lane-lock\test-shadow-lane-lock.ps1 -TestRepair
```
从项目根目录执行。自动创建随机名称 `FT546WMS_LaneLock_Test_<GUID>` 的小型测试库,只放人工数据;不会接收生产库作为测试目标。执行两个源 SQL 中真实的 rebuild 过程,指纹计算使用明确标注的测试桩。覆盖锁值、各排除规则、跨代次、跨仓库/通道、重复刷新、修复的精确范围、哈希拒绝、重复更新、DDL 失败回滚及恢复原文。退出时删除本次测试库;此测试不验证现场托盘的实际规划效果。

View File

@@ -0,0 +1,22 @@
IF NOT EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='UNLOCK_N' AND available_count=1)
THROW 51950,'locked=N must be available in the actual lane aggregate',1;
IF (SELECT COUNT(*) FROM alg.lane_state WHERE warehouse_id='01' AND lane_code LIKE 'UNLOCK_%' AND available_count=1 AND pending_outbound=0 AND has_gap=0 AND snapshot_generation=101)<>7
THROW 51950,'NULL, blank, 0, N, trimmed N, released reservation and old generation must remain eligible',1;
IF EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='01' AND lane_code NOT LIKE 'UNLOCK_%' AND available_count<>0)
THROW 51950,'locked/unknown/occupied/tray/stock/role/reservation/outbound/gap exclusions must remain',1;
IF NOT EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='OUTBOUND' AND pending_outbound=1 AND available_count=0)
THROW 51950,'outbound must exclude the entire lane',1;
IF NOT EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='GAP' AND has_gap=1 AND available_count=0)
THROW 51950,'gap must exclude the entire lane',1;
IF NOT EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='UNLOCK_N' AND space_count=1 AND occupied_count=0)
THROW 51950,'old generation must not pollute current counts',1;
DECLARE @stamp datetime2=(SELECT rebuilt_at FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='UNLOCK_N');
EXEC alg.usp_rebuild_lane_state @warehouse_id='01';
IF (SELECT rebuilt_at FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='UNLOCK_N')<>@stamp
THROW 51950,'unchanged repeat rebuild should not rewrite lane state',1;
EXEC alg.usp_rebuild_lane_state @warehouse_id='02';
EXEC alg.usp_rebuild_lane_state @warehouse_id='01',@lane_code='UNLOCK_N';
IF NOT EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='02' AND lane_code='OTHER_WAREHOUSE' AND available_count=1 AND snapshot_generation=201)
THROW 51950,'scoped rebuild must preserve other warehouse',1;
IF (SELECT COUNT(*) FROM alg.lane_state WHERE warehouse_id='01')<>16
THROW 51950,'scoped rebuild must preserve other lanes',1;

View File

@@ -0,0 +1,43 @@
-- Only synthetic fixtures; this script is run exclusively in a randomly named scratch DB.
CREATE TABLE alg.cargo_space_snapshot(warehouse_id varchar(80),space_id varchar(80),lane_code varchar(80),depth_raw varchar(10),space_status varchar(10),locked varchar(10),tray_code varchar(80),snapshot_generation bigint);
CREATE TABLE alg.space_inventory_summary(warehouse_id varchar(80),space_id varchar(80),stock_detail_count int,snapshot_generation bigint);
CREATE TABLE alg.space_role(space_id varchar(80),role varchar(40));
CREATE TABLE alg.active_job_snapshot(warehouse_id varchar(80),source_space_id varchar(80),in_or_out varchar(10),snapshot_generation bigint);
CREATE TABLE alg.reservation(reservation_id varchar(80),space_id varchar(80),status varchar(20));
CREATE TABLE alg.shadow_snapshot_state(warehouse_id varchar(80),snapshot_generation bigint,snapshot_status varchar(20));
CREATE TABLE alg.lane_state(warehouse_id varchar(80),lane_code varchar(80),space_count int,occupied_count int,available_count int,pending_outbound bit,has_gap bit,semantic_fingerprint varbinary(32),snapshot_generation bigint,rebuilt_at datetime2 DEFAULT SYSUTCDATETIME(),PRIMARY KEY(warehouse_id,lane_code));
GO
-- Explicit test stub: fingerprint internals are outside this aggregation regression.
-- The actual rebuild procedure, cursor, guards and MERGE are always executed unmodified.
CREATE PROCEDURE alg.usp_compute_lane_semantic_fingerprint
@warehouse_id varchar(80),@lane_code varchar(80),@semantic_fingerprint varbinary(32) OUTPUT,
@inventory_source varchar(20),@snapshot_generation bigint
AS
SET @semantic_fingerprint=HASHBYTES('SHA2_256',@warehouse_id+':'+@lane_code+':'+CONVERT(varchar(30),@snapshot_generation));
GO
INSERT alg.shadow_snapshot_state VALUES('01',101,'PUBLISHED'),('02',201,'PUBLISHED');
INSERT alg.cargo_space_snapshot VALUES
('01','N','UNLOCK_N','1','1','N',NULL,101),
('01','ZERO','UNLOCK_ZERO','1','1','0',NULL,101),
('01','NULL','UNLOCK_NULL','1','1',NULL,NULL,101),
('01','BLANK','UNLOCK_BLANK','1','1',' ',NULL,101),
('01','TRIM','UNLOCK_TRIM','1','1',' N ',NULL,101),
('01','Y','LOCK_Y','1','1','Y',NULL,101),
('01','UNKNOWN','LOCK_UNKNOWN','1','1','?',NULL,101),
('01','STATUS','OCCUPIED','1','2','N',NULL,101),
('01','TRAY','HAS_TRAY','1','1','N','T1',101),
('01','STOCK','HAS_STOCK','1','1','N',NULL,101),
('01','ROLE','PROTECTED','1','1','N',NULL,101),
('01','RESERVED','RESERVED','1','1','N',NULL,101),
('01','RELEASED','UNLOCK_RELEASED','1','1','N',NULL,101),
('01','OUT1','OUTBOUND','1','1','N',NULL,101),
('01','OUT2','OUTBOUND','2','1','N',NULL,101),
('01','GAP1','GAP','1','1','N',NULL,101),
('01','GAP2','GAP','2','2','N','T2',101),
('01','HISTORY','UNLOCK_HISTORY','1','1','N',NULL,101),
('01','N','UNLOCK_N','1','2','Y','OLD',100),
('02','W2','OTHER_WAREHOUSE','1','1','N',NULL,201);
INSERT alg.space_inventory_summary VALUES('01','STOCK',1,101),('01','HISTORY',1,100);
INSERT alg.space_role VALUES('ROLE','BUFFER');
INSERT alg.reservation VALUES('R1','RESERVED','ACTIVE'),('R2','RELEASED','RELEASED');
INSERT alg.active_job_snapshot VALUES('01','OUT1','20',101),('01','HISTORY','20',100);

View File

@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0repair-shadow-lane-lock.ps1" %*
exit /b %errorlevel%

View File

@@ -0,0 +1,136 @@
param(
[string]$Server = 'lpc:.',
[string]$Database = 'FT546-wms',
[ValidateSet('Check','Apply','Rollback')][string]$Mode = 'Check',
[string]$ExpectedSha256,
[string]$BackupFile
)
$ErrorActionPreference='Stop'
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
if($Database -ne 'FT546-wms' -and $Database -notmatch '^FT546WMS_LaneLock_Test_[a-f0-9]{32}$') {
throw 'Only FT546-wms or the dedicated scratch database is supported'
}
if($Mode -ne 'Check' -and ($ExpectedSha256 -notmatch '^[a-fA-F0-9]{64}$' -or -not $BackupFile)) {
throw 'Apply/Rollback requires ExpectedSha256 and BackupFile'
}
$old="NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL"
$new="(NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL OR LTRIM(RTRIM(cargo.locked)) IN ('0', 'N'))"
function Hash([string]$value) {
$sha=[Security.Cryptography.SHA256]::Create()
try { ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($value)))).Replace('-','').ToLowerInvariant() }
finally { $sha.Dispose() }
}
function Canonical([string]$value) {
if($value -notmatch '(?i)\A\s*(CREATE|ALTER)\s+PROCEDURE\s+alg\.usp_rebuild_lane_state\b') {
throw 'Unexpected procedure header'
}
[regex]::Replace($value,'(?i)\A\s*(CREATE|ALTER)\s+PROCEDURE','ALTER PROCEDURE')
}
$cs=[Data.SqlClient.SqlConnectionStringBuilder]::new()
$cs['Data Source']=$Server; $cs['Initial Catalog']=$Database; $cs['Integrated Security']=$true
$cs['Connect Timeout']=8; $cs['Application Name']='FT546ShadowLaneLockRepair'
$cn=[Data.SqlClient.SqlConnection]::new($cs.ConnectionString)
$transaction=$null
function Command([string]$sql) {
$cmd=$cn.CreateCommand(); $cmd.CommandTimeout=10; $cmd.CommandText=$sql
if($script:transaction){$cmd.Transaction=$script:transaction}
return $cmd
}
function Exec([string]$sql) {
$cmd=Command $sql
try { [void]$cmd.ExecuteNonQuery() } finally { $cmd.Dispose() }
}
function Scalar([string]$sql) {
$cmd=Command $sql
try { $cmd.ExecuteScalar() } finally { $cmd.Dispose() }
}
function ReadDefinition {
$cmd=Command @'
SELECT m.definition,m.uses_ansi_nulls,m.uses_quoted_identifier
FROM sys.sql_modules m JOIN sys.procedures p ON p.object_id=m.object_id
WHERE p.object_id=OBJECT_ID(N'alg.usp_rebuild_lane_state',N'P');
'@
try {
$reader=$cmd.ExecuteReader()
try {
if(-not $reader.Read() -or $reader.IsDBNull(0)){throw 'Procedure missing or encrypted'}
[pscustomobject]@{definition=$reader.GetString(0);ansi=$reader.GetBoolean(1);quoted=$reader.GetBoolean(2)}
} finally {$reader.Dispose()}
} finally {$cmd.Dispose()}
}
$cn.Open()
try {
Exec 'SET NOCOUNT ON; SET XACT_ABORT ON; SET LOCK_TIMEOUT 1500; SET DEADLOCK_PRIORITY LOW;'
$instance=[string](Scalar 'SELECT CONVERT(nvarchar(128),SERVERPROPERTY(''ServerName''))')
$before=ReadDefinition
$beforeHash=Hash $before.definition
if($ExpectedSha256 -and $beforeHash -ne $ExpectedSha256){throw 'Procedure hash mismatch; recapture and review before retrying'}
$null=Canonical $before.definition
$fixedCount=([regex]::Matches($before.definition,[regex]::Escape($new))).Count
$oldCount=([regex]::Matches($before.definition,[regex]::Escape($old))).Count
if($oldCount -ne 1 -or $fixedCount -gt 1){throw 'Unexpected lock predicate; no changes made'}
if($Mode -eq 'Check') {
[ordered]@{mode=$Mode;server=$instance;database=$Database;sha256=$beforeHash;state=$(if($fixedCount -eq 1){'ALREADY_FIXED'}else{'NEEDS_FIX'});ansi=$before.ansi;quoted=$before.quoted}|ConvertTo-Json -Compress
return
}
if($Mode -eq 'Apply' -and $fixedCount -eq 1) {
[ordered]@{mode=$Mode;status='ALREADY_FIXED';sha256=$beforeHash;database_written=$false}|ConvertTo-Json -Compress
return
}
if($Mode -eq 'Apply') {
$target=(Canonical $before.definition).Replace($old,$new)
$backup=[ordered]@{
format='FT546WMS_SHADOW_LANE_LOCK_20260914';server=$instance;database=$Database
utc=[DateTime]::UtcNow.ToString('o');definition=$before.definition;original_sha256=$beforeHash
fixed_canonical_sha256=(Hash $target);ansi=$before.ansi;quoted=$before.quoted
}
# Backup is durable before beginning DDL; no existing file can be overwritten.
$stream=[IO.File]::Open($BackupFile,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
try {
$bytes=[Text.Encoding]::UTF8.GetBytes(($backup|ConvertTo-Json -Depth 4))
$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)
} finally {$stream.Dispose()}
} else {
$backup=[IO.File]::ReadAllText($BackupFile)|ConvertFrom-Json
if($backup.format -ne 'FT546WMS_SHADOW_LANE_LOCK_20260914' -or $backup.server -cne $instance -or $backup.database -cne $Database) {
throw 'Backup identity mismatch'
}
if((Hash $backup.definition) -ne $backup.original_sha256){throw 'Backup integrity mismatch'}
if($fixedCount -ne 1 -or (Hash (Canonical $before.definition)) -ne $backup.fixed_canonical_sha256) {
throw 'Rollback refuses drift from the backed-up repair'
}
$target=Canonical $backup.definition
}
# Only the alg procedure is changed. No rebuild of the currently published generation,
# no dbo DML, no application restart, no full migration and no active session is killed.
$transaction=$cn.BeginTransaction()
$lockResult=Scalar "DECLARE @r int; EXEC @r=sys.sp_getapplock @Resource=N'FT546WMS_ShadowLaneLockRepair',@LockMode='Exclusive',@LockOwner='Transaction',@LockTimeout=0; SELECT @r;"
if($lockResult -lt 0){throw 'Another lane-lock repair is running'}
$recheck=ReadDefinition
if((Hash $recheck.definition) -ne $beforeHash -or $recheck.ansi -ne $before.ansi -or $recheck.quoted -ne $before.quoted) {
throw 'Procedure changed during preparation; no changes made'
}
# Refuse unreviewed database DDL hooks that could make ALTER write business data.
if((Scalar 'SELECT COUNT(*) FROM sys.triggers WHERE parent_class=0 AND is_disabled=0') -gt 0) {
throw 'Enabled database DDL triggers require review before this repair'
}
$ansi=if($backup.ansi){'ON'}else{'OFF'}
$quoted=if($backup.quoted){'ON'}else{'OFF'}
Exec "SET ANSI_NULLS $ansi; SET QUOTED_IDENTIFIER $quoted;"
$watch=[Diagnostics.Stopwatch]::StartNew()
Exec $target
$after=ReadDefinition
if((Canonical $after.definition) -cne $target -or $after.ansi -ne $backup.ansi -or $after.quoted -ne $backup.quoted) {
throw 'Definition verification failed; rolling back'
}
$transaction.Commit();$transaction.Dispose();$transaction=$null
$watch.Stop()
[ordered]@{
mode=$Mode;status='OK';server=$instance;database=$Database;object='alg.usp_rebuild_lane_state'
before_sha256=$beforeHash;after_sha256=(Hash $after.definition);ddl_ms=$watch.ElapsedMilliseconds
backup=$BackupFile;refresh_invoked=$false;utc=[DateTime]::UtcNow.ToString('o')
}|ConvertTo-Json -Compress
} finally {
if($transaction){try{$transaction.Rollback()}finally{$transaction.Dispose()}}
$cn.Dispose()
}

View File

@@ -0,0 +1,104 @@
param(
[string]$Server = 'lpc:.',
[string]$SqlDirectory = "$PSScriptRoot/../../fortucky-algorithm/src/main/resources/db/sqlserver",
[switch]$TestRepair
)
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
# This runner accepts no database name: fixtures can only enter its own tiny scratch DB.
$testDb = 'FT546WMS_LaneLock_Test_' + [Guid]::NewGuid().ToString('N')
$cs = [Data.SqlClient.SqlConnectionStringBuilder]::new()
$cs['Data Source']=$Server; $cs['Initial Catalog']='master'; $cs['Integrated Security']=$true
$cs['Connect Timeout']=8; $cs['Application Name']='FT546ShadowLaneLockTests'
$cn = [Data.SqlClient.SqlConnection]::new($cs.ConnectionString)
function Exec([string]$sql) {
$cmd=$cn.CreateCommand(); $cmd.CommandTimeout=15; $cmd.CommandText=$sql
try { [void]$cmd.ExecuteNonQuery() } finally { $cmd.Dispose() }
}
function Scalar([string]$sql) {
$cmd=$cn.CreateCommand(); $cmd.CommandTimeout=15; $cmd.CommandText=$sql
try { $cmd.ExecuteScalar() } finally { $cmd.Dispose() }
}
function Assert([bool]$condition,[string]$message) {
if(-not $condition){throw "FAIL: $message"}
Write-Output "PASS: $message"
}
function Hash([string]$value) {
$sha=[Security.Cryptography.SHA256]::Create()
try { ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($value)))).Replace('-','').ToLowerInvariant() }
finally { $sha.Dispose() }
}
function Definition { [string](Scalar "SELECT OBJECT_DEFINITION(OBJECT_ID('alg.usp_rebuild_lane_state'))") }
function Canonical([string]$value) { [regex]::Replace($value,'(?i)\A\s*(CREATE|ALTER)\s+PROCEDURE','ALTER PROCEDURE') }
$created=$false
$cn.Open()
try {
$dataPath=([string](Scalar "SELECT CONVERT(nvarchar(4000),SERVERPROPERTY('InstanceDefaultDataPath'))")).Replace("'","''")
$logPath=([string](Scalar "SELECT CONVERT(nvarchar(4000),SERVERPROPERTY('InstanceDefaultLogPath'))")).Replace("'","''")
Exec "CREATE DATABASE [$testDb] ON PRIMARY (NAME=N'$testDb',FILENAME=N'${dataPath}$testDb.mdf',SIZE=8MB,MAXSIZE=64MB,FILEGROWTH=8MB) LOG ON (NAME=N'${testDb}_log',FILENAME=N'${logPath}${testDb}_log.ldf',SIZE=8MB,MAXSIZE=64MB,FILEGROWTH=8MB)"
$created=$true; $cn.ChangeDatabase($testDb)
Exec 'SET NOCOUNT ON; SET LOCK_TIMEOUT 1500; SET DEADLOCK_PRIORITY LOW;'
Exec 'CREATE SCHEMA alg'
foreach($batch in ([IO.File]::ReadAllText("$PSScriptRoot/fixture.sql") -split '(?im)^\s*GO\s*\r?$')) {
if($batch.Trim()){ Exec $batch }
}
foreach($source in @('install-shadow.sql','upgrade-shadow-v2-to-v3.sql')) {
$text=[IO.File]::ReadAllText((Join-Path $SqlDirectory $source))
$bodies=@($text -split '(?im)^\s*GO\s*\r?$' | Where-Object {$_ -match '(?im)^\s*(CREATE|ALTER) PROCEDURE alg\.usp_rebuild_lane_state\b'})
Assert ($bodies.Count -eq 1) "$source has exactly one real rebuild body"
$body=$bodies[0]
$start=[regex]::Match($body,'(?im)^\s*(CREATE|ALTER) PROCEDURE alg\.usp_rebuild_lane_state\b').Index
$body=$body.Substring($start).Trim()
Exec "IF OBJECT_ID('alg.usp_rebuild_lane_state') IS NOT NULL DROP PROCEDURE alg.usp_rebuild_lane_state"
Exec ([regex]::Replace($body,'(?i)\A(CREATE|ALTER) PROCEDURE','CREATE PROCEDURE'))
Exec 'DELETE alg.lane_state'
Exec "EXEC alg.usp_rebuild_lane_state @warehouse_id='01'"
Exec ([IO.File]::ReadAllText("$PSScriptRoot/assertions.sql"))
Write-Output "PASS: $source actual T-SQL aggregation, exclusions, repeat and scoping"
if($TestRepair) {
$old="NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL"
$new="(NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL OR LTRIM(RTRIM(cargo.locked)) IN ('0', 'N'))"
$original=(Canonical (Definition)).Replace($new,$old)
Exec $original
$before=Definition
$backup=Join-Path $PSScriptRoot "$testDb-$source.backup.json"
$repair=Join-Path $PSScriptRoot 'repair-shadow-lane-lock.ps1'
try {
$null=& $repair -Server $Server -Database $testDb -Mode Check -ExpectedSha256 (Hash $before)
Assert ((Definition) -ceq $before) 'check mode leaves procedure unchanged'
$null=& $repair -Server $Server -Database $testDb -Mode Apply -ExpectedSha256 (Hash $before) -BackupFile $backup
$fixed=Definition
Assert ((Canonical $fixed) -ceq (Canonical $before).Replace($old,$new)) 'repair changes only the lock predicate'
Exec "EXEC alg.usp_rebuild_lane_state @warehouse_id='01'"
Exec ([IO.File]::ReadAllText("$PSScriptRoot/assertions.sql"))
$null=& $repair -Server $Server -Database $testDb -Mode Apply -ExpectedSha256 (Hash $fixed) -BackupFile $backup
Assert ((Definition) -ceq $fixed) 'repeat apply is a no-op'
$refused=$false
try { $null=& $repair -Server $Server -Database $testDb -Mode Apply -ExpectedSha256 ('0'*64) -BackupFile $backup }
catch { $refused=$_.Exception.Message -like '*hash mismatch*' }
Assert $refused 'unexpected definition is refused'
# Invalid ALTER must roll back atomically, including connection transaction cleanup.
$tx=$cn.BeginTransaction()
$cmd=$cn.CreateCommand(); $cmd.Transaction=$tx; $cmd.CommandText='ALTER PROCEDURE alg.usp_rebuild_lane_state AS INVALID SQL HERE'
$failed=$false
try { [void]$cmd.ExecuteNonQuery() } catch { $failed=$true } finally { $tx.Rollback();$cmd.Dispose();$tx.Dispose() }
Assert ($failed -and (Definition) -ceq $fixed) 'failed ALTER preserves the published procedure'
$null=& $repair -Server $Server -Database $testDb -Mode Rollback -ExpectedSha256 (Hash $fixed) -BackupFile $backup
Assert ((Canonical (Definition)) -ceq (Canonical $before)) 'rollback restores the exact original body'
Exec "EXEC alg.usp_rebuild_lane_state @warehouse_id='01'"
Assert ((Scalar "SELECT available_count FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='UNLOCK_N'") -eq 0) 'rollback reproduces the original N exclusion'
} finally {
if(Test-Path -LiteralPath $backup){ Remove-Item -LiteralPath $backup }
}
}
}
} finally {
if($created) {
$cn.ChangeDatabase('master')
[Data.SqlClient.SqlConnection]::ClearAllPools()
if($testDb -notmatch '^FT546WMS_LaneLock_Test_[a-f0-9]{32}$'){throw 'Invalid scratch database name'}
Exec "DROP DATABASE [$testDb]"
Write-Output "Scratch database removed: $testDb"
}
$cn.Dispose()
}

View 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;