Compare commits
8 Commits
0bd47e2b2c
...
feature/ft
| Author | SHA1 | Date | |
|---|---|---|---|
| 2632a57d73 | |||
| 67251a7562 | |||
| 58e9832b61 | |||
| a0af05bfa2 | |||
| acc6994385 | |||
| 8dbdd411bf | |||
| ae7f8c08f4 | |||
| 63ed8f72b1 |
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.
|
||||
@@ -756,7 +756,7 @@ BEGIN
|
||||
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 = '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 ISNULL(stock.stock_detail_count, 0) = 0
|
||||
AND ISNULL(role.role, 'STORAGE') = 'STORAGE'
|
||||
|
||||
@@ -775,7 +775,7 @@ BEGIN
|
||||
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 = '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 ISNULL(stock.stock_detail_count, 0) = 0
|
||||
AND ISNULL(role.role, 'STORAGE') = 'STORAGE'
|
||||
|
||||
@@ -283,6 +283,17 @@ public class ShadowObservationGateway {
|
||||
metrics.increment(ShadowSkipReason.CONCURRENT);
|
||||
} catch (Exception shadowFailure) {
|
||||
coordinator.failRefresh(ticket, failureDescription(failureStage, shadowFailure));
|
||||
if (isSourceChanged(shadowFailure)) {
|
||||
// SQL 51025 is the stored procedure's optimistic-concurrency guard:
|
||||
// source rows changed while the snapshot was being built. Treat it as
|
||||
// a stale refresh and defer the next attempt; retrying immediately only
|
||||
// creates another collision and incorrectly reports an application error.
|
||||
metrics.increment(ShadowSkipReason.STALE);
|
||||
recoveryNotBefore.set(clock.instant().plus(RECOVERY_COOLDOWN));
|
||||
LOG.info("shadow_refresh_stale warehouse={} snapshotGeneration={} sqlCode=51025",
|
||||
warehouseId, generation);
|
||||
return;
|
||||
}
|
||||
metrics.recordError(failureStage);
|
||||
warnFailure("CONTROL", generation, failureStage, shadowFailure, startedNanos);
|
||||
}
|
||||
@@ -290,6 +301,21 @@ public class ShadowObservationGateway {
|
||||
recoveryNotBefore.set(clock.instant().plus(RECOVERY_COOLDOWN));
|
||||
}
|
||||
|
||||
private boolean isSourceChanged(Throwable failure) {
|
||||
Throwable cause = failure;
|
||||
for (int depth = 0; depth < 8 && cause != null; depth++) {
|
||||
if (cause instanceof SQLException sql && sql.getErrorCode() == 51025) {
|
||||
return true;
|
||||
}
|
||||
Throwable next = cause.getCause();
|
||||
if (next == cause) {
|
||||
break;
|
||||
}
|
||||
cause = next;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Optional<ShadowSnapshotCoordinator.RefreshTicket> tryBeginRefreshAfterQuietWindow()
|
||||
throws InterruptedException {
|
||||
if (coordinator.activeLegacyCount() != 0) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
@@ -392,6 +393,24 @@ class ShadowObservationGatewayTest {
|
||||
assertThat(recovered.await(1, TimeUnit.SECONDS)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceChangedRefreshIsClassifiedAsStaleAndDoesNotRetry() throws Exception {
|
||||
ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class);
|
||||
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
|
||||
SQLException sourceChanged = new SQLException("source changed", "S0001", 51025);
|
||||
when(engine.refreshSnapshot(anyLong(), eq("INSTANCE-1"))).thenThrow(new RuntimeException(sourceChanged));
|
||||
CountDownLatch staleMetric = metricLatch(mapper, "SKIP_STALE");
|
||||
ShadowObservationGateway gateway = gateway(engine, mapper, coordinator(),
|
||||
new ShadowRuntimeMetrics(), Clock.fixed(NOW, ZoneOffset.UTC), delay -> { });
|
||||
|
||||
gateway.start();
|
||||
|
||||
assertThat(staleMetric.await(1, TimeUnit.SECONDS)).isTrue();
|
||||
verify(engine, timeout(1000).times(1)).refreshSnapshot(anyLong(), eq("INSTANCE-1"));
|
||||
verify(mapper, never()).upsertRuntimeMetric(anyString(), eq("ERROR_REFRESH"), anyLong(),
|
||||
anyLong(), anyLong(), anyLong(), any(Instant.class));
|
||||
}
|
||||
|
||||
private void assertOutboundUsesLease(boolean exactStock) throws Exception {
|
||||
ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class);
|
||||
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
92
scripts/mixed-pilot/README.md
Normal file
92
scripts/mixed-pilot/README.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# FT546WMS 指定通道混放试点
|
||||
|
||||
这是旧版分配规则的应急补丁,与影子算法是否接管无关。无需更换 WMS/WCS JAR;安装后默认关闭,启用后才允许新增混放。
|
||||
|
||||
## 试点范围
|
||||
|
||||
| 通道 | 内侧原项目位置 | 外侧可混放位置 | 当前新增容量 |
|
||||
| --- | --- | --- | --- |
|
||||
| C1203 | 120803 | 120703 | 1 托盘 |
|
||||
| C0204 | 020804 | 020704 | 1 托盘 |
|
||||
| C0205 | 020805 | 020705 | 1 托盘 |
|
||||
|
||||
白名单按**整条通道**设置,入库和出库保护覆盖两端。三条双深通道共六个位置,内侧已有货,因此本次最多新增三个同时在库的客居托盘。不会把全库零散空位一起开放。
|
||||
|
||||
现场于 2026-09-10 确认所有项目允许分托盘共用通道,并确认这三个外侧位置适配当前托盘。一个托盘仍只能有一个明确项目;999/无项目托盘保持原 B/D 区规则。项目号和库存归属不改写。
|
||||
|
||||
原规则找到货位时优先采用原分配。原规则返回无货位时,才尝试白名单通道的外侧空位。每通道最多两个项目,内侧不能越过外侧托盘补货;有在途任务或锁定时拒绝。新增混放时,同层必须至少有两个可通行的 E 区空位。该检查不是长期预留,后续移库仍会重新检查和占用目标。
|
||||
|
||||
## CMD 操作
|
||||
|
||||
在服务器 CMD 中进入本文件所在目录。命令使用 Windows 集成认证,CMD 会自动调用系统 PowerShell,无需在命令里填写数据库密码。
|
||||
|
||||
1. 安装(开关仍关闭):
|
||||
|
||||
```bat
|
||||
call mixed-pilot.cmd install "lpc:." "FT546-wms"
|
||||
```
|
||||
|
||||
出现 `INSTALL_OK` 才表示成功。安装核对三个原存储过程的 SHA-256,版本不符、锁等待超过两秒或执行失败时,事务回滚,不覆盖现场改动。脚本创建触发器和替换过程时需要短暂架构锁;业务繁忙导致安装失败时稍后重试,不能去杀业务会话。
|
||||
|
||||
2. 查询配置和现场状态:
|
||||
|
||||
```bat
|
||||
call mixed-pilot.cmd status "lpc:." "FT546-wms"
|
||||
```
|
||||
|
||||
3. 已确认所有项目可混放、托盘适配后开启:
|
||||
|
||||
```bat
|
||||
call mixed-pilot.cmd enable "lpc:." "FT546-wms" CONFIRM-FIT-ALL-PROJECTS
|
||||
```
|
||||
|
||||
出现 `ENABLED` 才表示三条通道均已启用。启用会重新核对内侧库存、外侧空位和在途任务,条件变化则整次拒绝。
|
||||
|
||||
4. 像原来一样创建入库任务。出库优先使用**自动分配**,它会按旧程序先为外侧托盘建立移库,再建立内侧出库。手工选择被挡住的内侧库存、却没有外侧腾挪任务时,会明确拒绝,不能手动把等待任务改为已下发或已完成。
|
||||
|
||||
## 观察和退出
|
||||
|
||||
内侧任务等待期间状态为 `8`,含义是“等待前位清空”;现有界面字典未配置该标签时可能直接显示数字 8。`status` 命令的 `waiting` 给出任务清单。只有外侧货位、库存和在途任务均已清空,内侧任务才恢复状态 `1`,由现有 WMS 定时推送交给 WCS。创建移库任务本身不会释放等待。
|
||||
|
||||
先验证一组完整业务:新项目入库 → 前位正常出库 → 再混放一托 → 后位自动出库并完成前位移库。测试通过后再增加通道数量;本包不开放 A 区深通道,也不切换整套新算法。
|
||||
|
||||
出现异常时先关闭**新增混放**,无需重启:
|
||||
|
||||
```bat
|
||||
call mixed-pilot.cmd disable "lpc:." "FT546-wms"
|
||||
```
|
||||
|
||||
关闭会保留已混放库存的出库保护,不会自动撤销库存位置或移库任务。不要直接删除触发器或覆盖存储过程。
|
||||
|
||||
完整卸载必须先关闭开关,三条试点通道清空、相关移库任务和移出到 E 区的试点托盘也全部处理完:
|
||||
|
||||
```bat
|
||||
call mixed-pilot.cmd uninstall "lpc:." "FT546-wms"
|
||||
```
|
||||
|
||||
卸载恢复三个原过程的完整逻辑、移除试点触发器和辅助过程,保留 `mixed_pilot` 中的原定义与审计表。未清空时拒绝卸载。卸载后的再次安装需要重新准备版本,避免覆盖已有审计资料。
|
||||
|
||||
导出观察记录发回分析:
|
||||
|
||||
```bat
|
||||
call mixed-pilot.cmd status "lpc:." "FT546-wms" > mixed-pilot-status.json
|
||||
```
|
||||
|
||||
同时提供对应时间段 WMS/WCS 日志,以及实际入库、前位出库、后位移库出库的任务号和现场结果。仅有安装成功或一段无报错日志不能证明设备完整流程通过。
|
||||
|
||||
## 已完成的程序验证
|
||||
|
||||
在 SQL Server 2016 上创建独立小型测试数据库,只使用合成数据和读取到的现场原过程,不连接设备或调用业务 HTTP。已验证默认关闭、普通分配优先、白名单与项目一致性、拒绝第三项目、重复分配、前后位派发顺序、移库目标校验、重复完成回调、开关关闭仍保护、两个连接争抢同一空位、安装漂移拒绝及卸载恢复。
|
||||
|
||||
还直接执行了现场 `proc_out_auto_allocation`:确认实际生成的移库任务搬的是外侧客居托盘,内侧出库进入等待,移库完成后才放行。这些是数据库行为验证,设备现场往返仍需通过上述小范围业务试点确认。
|
||||
|
||||
本补丁替换 `proc_allot_space`、`proc_yk_job_add`、`proc_yk_complete` 三个入口并保留原定义,不修改两个出库分配过程。增加三处数据库触发器,涉及 `mydo_job`、`bas_cargo_space`、`bas_stock`。未登记通道仍使用原分配与派发规则;试点启用期间,入库分配增加短时间串行校验,移库增加目标锁检查,争用时可能需要重试。
|
||||
|
||||
开发人员复测(应在安装前采集原过程;测试只创建独立、随机命名的测试数据库):
|
||||
|
||||
```bat
|
||||
powershell.exe -NoProfile -NonInteractive -File capture-test-fixture.ps1 -Server "lpc:." -Database "FT546-wms" > fixture.json
|
||||
powershell.exe -NoProfile -NonInteractive -File test-mixed-pilot.ps1 -Server "lpc:." -FixtureJson fixture.json
|
||||
```
|
||||
|
||||
测试库初始数据文件与日志各 8 MB,上限各 64 MB,正常结束或失败后都会尝试清理。测试不恢复生产库,不调用 WMS/WCS 的业务接口;原序列号过程用测试序列替代,合成表保留字段类型和顺序、放宽非空约束,因此不覆盖全部生产约束及设备流程。
|
||||
20
scripts/mixed-pilot/capture-test-fixture.ps1
Normal file
20
scripts/mixed-pilot/capture-test-fixture.ps1
Normal file
@@ -0,0 +1,20 @@
|
||||
param([Parameter(Mandatory=$true)][string]$Server,[Parameter(Mandatory=$true)][string]$Database)
|
||||
$ErrorActionPreference='Stop'
|
||||
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
|
||||
$builder=[Data.SqlClient.SqlConnectionStringBuilder]::new()
|
||||
$builder['Data Source']=$Server;$builder['Initial Catalog']=$Database;$builder['Integrated Security']=$true
|
||||
$builder['Connect Timeout']=8;$builder['Application Name']='MixedPilotFixtureReadOnly'
|
||||
$cn=[Data.SqlClient.SqlConnection]::new($builder.ConnectionString);$cn.Open()
|
||||
function Query([string]$sql){
|
||||
$c=$cn.CreateCommand();$c.CommandTimeout=10;$c.CommandText='SET NOCOUNT ON;SET LOCK_TIMEOUT 2000;'+$sql
|
||||
$t=[Data.DataTable]::new();try{$r=$c.ExecuteReader();try{$t.Load($r)}finally{$r.Dispose()}}finally{$c.Dispose()}
|
||||
foreach($row in $t.Rows){$o=[ordered]@{};foreach($col in $t.Columns){$v=$row[$col.ColumnName];$o[$col.ColumnName]=if($v -is [DBNull]){$null}else{$v}};[pscustomobject]$o}
|
||||
}
|
||||
try {
|
||||
# Definitions and column metadata only: no production inventory/order/task rows are exported.
|
||||
$fixture=[ordered]@{
|
||||
columns=@(Query "SELECT o.name table_name,c.name column_name,TYPE_NAME(c.user_type_id) type_name,c.max_length,c.precision,c.scale,c.is_nullable,c.is_identity FROM sys.objects o JOIN sys.columns c ON c.object_id=o.object_id WHERE o.schema_id=SCHEMA_ID('dbo') AND o.name IN('bas_cargo_space','bas_stock','mydo_job','mydo_job_detail','mydo_job_finish','bas_led','move_msg','out_stockout','out_stockout_detail') ORDER BY o.name,c.column_id")
|
||||
wms_objects=@(Query "SELECT o.name,m.definition FROM sys.objects o JOIN sys.sql_modules m ON m.object_id=o.object_id WHERE o.schema_id=SCHEMA_ID('dbo') AND o.name IN('cargo_space_group_count','proc_allot_space','proc_yk_job_add','proc_yk_complete','proc_out_auto_allocation','proc_out_hand_allocation')")
|
||||
}
|
||||
$fixture|ConvertTo-Json -Depth 6 -Compress
|
||||
}finally{$cn.Dispose()}
|
||||
101
scripts/mixed-pilot/control-mixed-pilot.ps1
Normal file
101
scripts/mixed-pilot/control-mixed-pilot.ps1
Normal file
@@ -0,0 +1,101 @@
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$Server,
|
||||
[Parameter(Mandatory=$true)][string]$Database,
|
||||
[ValidateSet('status','enable','disable','uninstall')][string]$Mode='status',
|
||||
[switch]$ConfirmPhysicalFit,
|
||||
[switch]$AllProjectsMayMix,
|
||||
[switch]$TestDatabase
|
||||
)
|
||||
$ErrorActionPreference='Stop'
|
||||
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
|
||||
if($TestDatabase -and $Database -notmatch '^FT546WMS_MixedPilot_Test_[a-f0-9]{32}$'){throw 'Invalid test database name'}
|
||||
if(-not $TestDatabase -and $Database -ine 'FT546-wms'){throw 'Only FT546-wms is supported'}
|
||||
$builder=[Data.SqlClient.SqlConnectionStringBuilder]::new()
|
||||
$builder['Data Source']=$Server;$builder['Initial Catalog']=$Database;$builder['Integrated Security']=$true
|
||||
$builder['Connect Timeout']=8;$builder['Application Name']='FT546MixedPilotControl'
|
||||
$cn=[Data.SqlClient.SqlConnection]::new($builder.ConnectionString);$cn.Open();$tx=$null
|
||||
function Cmd([string]$sql){$c=$cn.CreateCommand();$c.CommandText=$sql;$c.CommandTimeout=15;if($tx){$c.Transaction=$tx};$c}
|
||||
function Exec([string]$sql){$c=Cmd $sql;try{[void]$c.ExecuteNonQuery()}finally{$c.Dispose()}}
|
||||
function Scalar([string]$sql){$c=Cmd $sql;try{$c.ExecuteScalar()}finally{$c.Dispose()}}
|
||||
function Query([string]$sql){
|
||||
$c=Cmd $sql;$t=[Data.DataTable]::new()
|
||||
try{$r=$c.ExecuteReader();try{$t.Load($r)}finally{$r.Dispose()}}finally{$c.Dispose()}
|
||||
foreach($row in $t.Rows){$o=[ordered]@{};foreach($col in $t.Columns){$v=$row[$col.ColumnName];$o[$col.ColumnName]=if($v -is [DBNull]){$null}else{$v}};[pscustomobject]$o}
|
||||
}
|
||||
try {
|
||||
Exec 'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET DEADLOCK_PRIORITY LOW;'
|
||||
if((Scalar "SELECT OBJECT_ID('mixed_pilot.control','U')") -is [DBNull]){
|
||||
if($Mode -eq 'status'){Write-Output '{"installed":false}';return};throw 'Pilot is not installed'
|
||||
}
|
||||
if($Mode -eq 'status'){
|
||||
$report=[ordered]@{
|
||||
utc=[DateTime]::UtcNow.ToString('o')
|
||||
control=@(Query 'SELECT * FROM mixed_pilot.control')
|
||||
slots=@(Query 'SELECT l.*,s.cargo_space_id,s.group_coding,s.the_floor,s.status,s.locked,s.tray_code FROM mixed_pilot.lane l LEFT JOIN dbo.bas_cargo_space s ON s.warehouse_id=l.warehouse_id AND s.group_coding_no=l.lane_code ORDER BY l.lane_code,s.group_coding')
|
||||
admissions=@(Query 'SELECT TOP(100) * FROM mixed_pilot.admission ORDER BY created_at DESC')
|
||||
waiting=@(Query "SELECT j.job_id,j.tray_code,j.cargo_space_id,j.status,h.created_at FROM mixed_pilot.dispatch_hold h JOIN dbo.mydo_job j ON j.job_id=h.job_id WHERE j.status='8'")
|
||||
moves=@(Query 'SELECT * FROM mixed_pilot.move_audit WHERE completed_at IS NULL')
|
||||
}
|
||||
$report|ConvertTo-Json -Depth 6;return
|
||||
}
|
||||
$tx=$cn.BeginTransaction()
|
||||
Exec "DECLARE @r int; EXEC @r=sys.sp_getapplock @Resource='FT546.mixed_pilot.inbound',@LockMode='Exclusive',@LockOwner='Transaction',@LockTimeout=1000; IF @r<0 THROW 51142,'An inbound allocation is active; retry later',1;"
|
||||
if($Mode -eq 'disable'){
|
||||
Exec 'UPDATE mixed_pilot.control SET enabled=0 WHERE id=1; UPDATE mixed_pilot.lane SET enabled=0;'
|
||||
$tx.Commit();$tx.Dispose();$tx=$null;Write-Output 'DISABLED: no new mixed admissions; existing mixed-stock dispatch protection retained';return
|
||||
}
|
||||
if((Scalar 'SELECT installed FROM mixed_pilot.control WHERE id=1') -ne 1){throw 'Pilot hooks are already uninstalled'}
|
||||
Exec "IF EXISTS(SELECT 1 FROM mixed_pilot.module_backup WHERE installed_hash<>HASHBYTES('SHA2_256',OBJECT_DEFINITION(OBJECT_ID(object_name))) OR OBJECT_DEFINITION(OBJECT_ID(object_name)) IS NULL) OR EXISTS(SELECT 1 FROM mixed_pilot.installed_module WHERE definition_hash<>HASHBYTES('SHA2_256',OBJECT_DEFINITION(OBJECT_ID(object_name))) OR OBJECT_DEFINITION(OBJECT_ID(object_name)) IS NULL) THROW 51143,'Module drift detected; no changes made',1;"
|
||||
if($Mode -eq 'enable'){
|
||||
if(-not $ConfirmPhysicalFit -or -not $AllProjectsMayMix){throw 'Enable requires -ConfirmPhysicalFit and -AllProjectsMayMix; do not infer these from empty SQL height fields'}
|
||||
Exec @'
|
||||
IF (SELECT COUNT(*) FROM mixed_pilot.lane)<>3 OR EXISTS(SELECT 1 FROM mixed_pilot.lane WHERE warehouse_id<>'01' OR lane_code NOT IN('C1203','C0204','C0205'))
|
||||
THROW 51144,'Pilot whitelist differs from the reviewed three lanes',1;
|
||||
DECLARE @locked int;
|
||||
SELECT @locked=COUNT(*) FROM dbo.bas_cargo_space s WITH(UPDLOCK,HOLDLOCK)
|
||||
JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no;
|
||||
IF @locked<>6 OR EXISTS(SELECT 1 FROM mixed_pilot.lane l WHERE
|
||||
(SELECT COUNT(*) FROM dbo.bas_cargo_space s WHERE s.warehouse_id=l.warehouse_id AND s.group_coding_no=l.lane_code AND TRY_CONVERT(int,s.group_coding)=1 AND s.this_cargo_area='C' AND s.status='2' AND s.locked='N' AND NULLIF(s.tray_code,'') IS NOT NULL)<>1
|
||||
OR (SELECT COUNT(*) FROM dbo.bas_cargo_space s WHERE s.warehouse_id=l.warehouse_id AND s.group_coding_no=l.lane_code AND TRY_CONVERT(int,s.group_coding)=2 AND s.this_cargo_area='C' AND s.status='1' AND s.locked='N' AND NULLIF(s.tray_code,'') IS NULL)<>1)
|
||||
THROW 51145,'A pilot lane no longer has an occupied rear and empty unlocked front; refresh candidate review',1;
|
||||
IF EXISTS(SELECT 1 FROM dbo.mydo_job j WITH(UPDLOCK,HOLDLOCK) JOIN dbo.bas_cargo_space s ON s.cargo_space_id=j.cargo_space_id OR s.cargo_space_id=j.target_cargo_space_id OR s.tray_code=j.tray_code
|
||||
JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no)
|
||||
THROW 51146,'Pilot lane has an active task; no switches changed',1;
|
||||
IF EXISTS(SELECT 1 FROM dbo.bas_cargo_space s JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no
|
||||
WHERE (TRY_CONVERT(int,s.group_coding)=2 AND EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=s.cargo_space_id))
|
||||
OR (TRY_CONVERT(int,s.group_coding)=1 AND (
|
||||
(SELECT COUNT(DISTINCT spare4) FROM dbo.bas_stock x WHERE x.cargo_space_id=s.cargo_space_id)<>1
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=s.cargo_space_id AND (NULLIF(x.spare4,'') IS NULL OR x.spare4='999' OR x.tray_code<>s.tray_code OR x.tray_code IS NULL OR ISNULL(x.out_quantity,0)<>0)))))
|
||||
THROW 51147,'Pilot inventory/project consistency check failed',1;
|
||||
UPDATE mixed_pilot.control SET enabled=1,all_projects_may_mix=1 WHERE id=1;
|
||||
UPDATE mixed_pilot.lane SET enabled=1,physical_fit_confirmed=1;
|
||||
'@
|
||||
$tx.Commit();$tx.Dispose();$tx=$null;Write-Output 'ENABLED: C1203/120703, C0204/020704, C0205/020705; at most three simultaneous guest pallets';return
|
||||
}
|
||||
# Full uninstall requires all trial stock/tasks to be drained, including relocated trial pallets.
|
||||
Exec @'
|
||||
IF EXISTS(SELECT 1 FROM mixed_pilot.control WHERE enabled=1)
|
||||
THROW 51148,'Disable admission before uninstalling',1;
|
||||
IF EXISTS(SELECT 1 FROM dbo.bas_cargo_space s JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no
|
||||
WHERE s.status<>'1' OR NULLIF(s.tray_code,'') IS NOT NULL
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=s.cargo_space_id)
|
||||
OR EXISTS(SELECT 1 FROM dbo.mydo_job j WHERE j.cargo_space_id=s.cargo_space_id OR j.target_cargo_space_id=s.cargo_space_id))
|
||||
OR EXISTS(SELECT 1 FROM mixed_pilot.move_audit a JOIN dbo.bas_stock s ON s.tray_code=a.tray_code)
|
||||
OR EXISTS(SELECT 1 FROM mixed_pilot.move_audit a JOIN dbo.mydo_job j ON j.job_id=a.job_id)
|
||||
OR EXISTS(SELECT 1 FROM mixed_pilot.dispatch_hold h JOIN dbo.mydo_job j ON j.job_id=h.job_id AND j.status='8')
|
||||
THROW 51149,'Trial lanes or relocated trial pallets are not drained; keep protection installed',1;
|
||||
'@
|
||||
$backups=@(Query 'SELECT * FROM mixed_pilot.module_backup')
|
||||
foreach($b in $backups){
|
||||
Exec (('SET ANSI_NULLS '+$(if($b.ansi_nulls){'ON'}else{'OFF'}))+'; SET QUOTED_IDENTIFIER '+$(if($b.quoted_identifier){'ON'}else{'OFF'})+';')
|
||||
$original=[regex]::Replace($b.original_definition,'(?i)\bCREATE\s+PROCEDURE','ALTER PROCEDURE')
|
||||
Exec $original
|
||||
}
|
||||
Exec 'DROP TRIGGER dbo.tr_mixed_pilot_job_guard; DROP TRIGGER dbo.tr_mixed_pilot_slot_release; DROP TRIGGER dbo.tr_mixed_pilot_stock_release;'
|
||||
foreach($name in @('allot_space_with_fallback','original_allot_space','original_yk_job_add','original_yk_complete','check_inbound','try_fallback','check_move','release_ready_jobs')){Exec "DROP PROCEDURE mixed_pilot.[$name]"}
|
||||
Exec 'DROP VIEW mixed_pilot.blocked_jobs; UPDATE mixed_pilot.control SET installed=0,enabled=0 WHERE id=1;'
|
||||
$tx.Commit();$tx.Dispose();$tx=$null
|
||||
Write-Output 'UNINSTALLED: original procedure behavior restored; audit and original definitions retained in mixed_pilot tables'
|
||||
} catch {
|
||||
if($tx){try{$tx.Rollback()}catch{};$tx.Dispose();$tx=$null};throw
|
||||
} finally {$cn.Dispose()}
|
||||
92
scripts/mixed-pilot/install-mixed-pilot.ps1
Normal file
92
scripts/mixed-pilot/install-mixed-pilot.ps1
Normal file
@@ -0,0 +1,92 @@
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$Server,
|
||||
[Parameter(Mandatory=$true)][string]$Database,
|
||||
[switch]$TestDatabase
|
||||
)
|
||||
$ErrorActionPreference='Stop'
|
||||
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
|
||||
if($TestDatabase -and $Database -notmatch '^FT546WMS_MixedPilot_Test_[a-f0-9]{32}$'){throw 'Invalid isolated test database name'}
|
||||
if(-not $TestDatabase -and $Database -ine 'FT546-wms'){throw 'Only FT546-wms is supported'}
|
||||
$builder=[Data.SqlClient.SqlConnectionStringBuilder]::new()
|
||||
$builder['Data Source']=$Server; $builder['Initial Catalog']=$Database; $builder['Integrated Security']=$true
|
||||
$builder['Connect Timeout']=8; $builder['Application Name']='FT546MixedPilotInstaller'
|
||||
$cn=[Data.SqlClient.SqlConnection]::new($builder.ConnectionString);$cn.Open();$tx=$null
|
||||
function Cmd([string]$sql) {
|
||||
$c=$cn.CreateCommand();$c.CommandText=$sql;$c.CommandTimeout=15
|
||||
if($tx){$c.Transaction=$tx};return $c
|
||||
}
|
||||
function Exec([string]$sql) {$c=Cmd $sql;try{[void]$c.ExecuteNonQuery()}finally{$c.Dispose()}}
|
||||
function Scalar([string]$sql) {$c=Cmd $sql;try{$c.ExecuteScalar()}finally{$c.Dispose()}}
|
||||
function Hash([string]$s) {
|
||||
$sha=[Security.Cryptography.SHA256]::Create()
|
||||
try{([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::Unicode.GetBytes($s)))).Replace('-','')}finally{$sha.Dispose()}
|
||||
}
|
||||
function RenameProc([string]$sql,[string]$name) {
|
||||
$regex=[regex]::new('(?i)\bCREATE\s+PROCEDURE\s+(?:\[?dbo\]?\.)?\[?proc_[a-z_]+\]?')
|
||||
if($regex.Matches($sql).Count -ne 1){throw 'Unexpected procedure header'}
|
||||
$regex.Replace($sql,('CREATE PROCEDURE [mixed_pilot].['+$name+']'),1)
|
||||
}
|
||||
try {
|
||||
Exec 'SET LOCK_TIMEOUT 2000; SET DEADLOCK_PRIORITY LOW;'
|
||||
$tx=$cn.BeginTransaction()
|
||||
Exec "DECLARE @r int; EXEC @r=sys.sp_getapplock @Resource='FT546.mixed_pilot.install',@LockMode='Exclusive',@LockOwner='Transaction',@LockTimeout=0; IF @r<0 THROW 51140,'Another pilot installation is in progress',1;"
|
||||
if((Scalar "SELECT OBJECT_ID('mixed_pilot.control','U')") -isnot [DBNull]){
|
||||
if((Scalar 'SELECT installed FROM mixed_pilot.control WHERE id=1') -ne 1){throw 'Previously uninstalled package: audit data retained; prepare a new reviewed installation rather than overwriting it'}
|
||||
if((Scalar 'SELECT version FROM mixed_pilot.control WHERE id=1') -ne '20260910-1'){throw 'Unsupported existing mixed-pilot version'}
|
||||
if((Scalar "SELECT COUNT(*) FROM mixed_pilot.module_backup WHERE installed_hash<>HASHBYTES('SHA2_256',OBJECT_DEFINITION(OBJECT_ID(object_name))) OR OBJECT_DEFINITION(OBJECT_ID(object_name)) IS NULL") -ne 0){throw 'Installed procedure drift detected; refusing overwrite'}
|
||||
if((Scalar "SELECT OBJECT_ID('mixed_pilot.installed_module','U')") -is [DBNull]){throw 'Missing installed-module manifest'}
|
||||
if((Scalar "SELECT COUNT(*) FROM mixed_pilot.installed_module WHERE definition_hash<>HASHBYTES('SHA2_256',OBJECT_DEFINITION(OBJECT_ID(object_name))) OR OBJECT_DEFINITION(OBJECT_ID(object_name)) IS NULL") -ne 0){throw 'Pilot helper/trigger drift detected; refusing to report success'}
|
||||
foreach($table in @('lane','admission','move_audit','dispatch_hold','module_backup')){
|
||||
if((Scalar "SELECT OBJECT_ID('mixed_pilot.$table','U')") -is [DBNull]){throw "Missing pilot table: $table"}
|
||||
}
|
||||
$tx.Commit();$tx.Dispose();$tx=$null;Write-Output 'ALREADY_INSTALLED: switches and data preserved';return
|
||||
}
|
||||
$expected=[ordered]@{
|
||||
proc_allot_space='DFF37262FD49271E9E6E657F01697731F1FF73DCD6DECA1F340107D560E26850'
|
||||
proc_yk_job_add='72708181352148DAF9490DD59FCBD0E3AC7A8C849EE8FC4AFCBFA0B1AF79AB5B'
|
||||
proc_yk_complete='C80CD445262BB483E7A7BDCF7DB97A44AD07A09D491869A60C094C5037621131'
|
||||
}
|
||||
$original=@{}
|
||||
foreach($name in $expected.Keys){
|
||||
$definition=Scalar "SELECT OBJECT_DEFINITION(OBJECT_ID('dbo.$name'))"
|
||||
if($definition -is [DBNull] -or (Hash $definition) -ne $expected[$name]){throw "Site procedure differs from reviewed baseline: $name. Nothing installed."}
|
||||
$original[$name]=$definition
|
||||
}
|
||||
# No stock/tasks/slot metadata are changed by installation. Admission remains OFF.
|
||||
Exec "IF SCHEMA_ID('mixed_pilot') IS NOT NULL THROW 51141,'Unexpected pre-existing mixed_pilot schema',1;"
|
||||
Exec 'CREATE SCHEMA mixed_pilot AUTHORIZATION dbo'
|
||||
foreach($batch in ([IO.File]::ReadAllText("$PSScriptRoot/objects.sql") -split '(?im)^\s*GO\s*\r?$')){if($batch.Trim()){Exec $batch}}
|
||||
foreach($name in $expected.Keys){
|
||||
$c=Cmd "INSERT mixed_pilot.module_backup(object_name,original_definition,original_hash,ansi_nulls,quoted_identifier) SELECT @name,definition,HASHBYTES('SHA2_256',definition),uses_ansi_nulls,uses_quoted_identifier FROM sys.sql_modules WHERE object_id=OBJECT_ID(@name)"
|
||||
[void]$c.Parameters.AddWithValue('@name',"dbo.$name");try{[void]$c.ExecuteNonQuery()}finally{$c.Dispose()}
|
||||
$ansi=Scalar "SELECT ansi_nulls FROM mixed_pilot.module_backup WHERE object_name='dbo.$name'"
|
||||
$quoted=Scalar "SELECT quoted_identifier FROM mixed_pilot.module_backup WHERE object_name='dbo.$name'"
|
||||
Exec (('SET ANSI_NULLS '+$(if($ansi){'ON'}else{'OFF'}))+'; SET QUOTED_IDENTIFIER '+$(if($quoted){'ON'}else{'OFF'})+';')
|
||||
Exec (RenameProc $original[$name] ('original_'+$name.Substring(5)))
|
||||
}
|
||||
Exec 'SET ANSI_NULLS ON; SET QUOTED_IDENTIFIER ON;'
|
||||
$inbound=RenameProc $original['proc_allot_space'] 'allot_space_with_fallback'
|
||||
$noSlot=[regex]::new("(?is)set\s+@v_result\s*=\s*2\s+set\s+@v_resultmsg\s*=\s*'[^']+'\s+RETURN")
|
||||
if($noSlot.Matches($inbound).Count -ne 2){throw 'Expected two Legacy no-position exits'}
|
||||
$inbound=$noSlot.Replace($inbound,[Text.RegularExpressions.MatchEvaluator]{param($match)
|
||||
'EXEC mixed_pilot.try_fallback @v_job_id,@v_cargo_space_id OUTPUT;'+"`r`n"+
|
||||
'IF @v_cargo_space_id IS NULL BEGIN '+$match.Value+' END;'+"`r`n"+
|
||||
'GOTO mixed_pilot_destination;'
|
||||
})
|
||||
$destination=[regex]::new('(?i)SELECT\s+@v_job_id\s*=\s*job_id\s+from\s+mydo_job\s+where\s+tray_code\s*=\s*@v_tray_code')
|
||||
if($destination.Matches($inbound).Count -ne 1){throw 'Expected one common Legacy destination block'}
|
||||
$inbound=$destination.Replace($inbound,@'
|
||||
mixed_pilot_destination:
|
||||
EXEC mixed_pilot.check_inbound @v_job_id,@v_cargo_space_id;
|
||||
SELECT @v_job_id = job_id from mydo_job where tray_code = @v_tray_code
|
||||
'@)
|
||||
Exec $inbound
|
||||
foreach($batch in ([IO.File]::ReadAllText("$PSScriptRoot/wrappers.sql") -split '(?im)^\s*GO\s*\r?$')){if($batch.Trim()){Exec $batch}}
|
||||
Exec "UPDATE mixed_pilot.module_backup SET installed_hash=HASHBYTES('SHA2_256',OBJECT_DEFINITION(OBJECT_ID(object_name)))"
|
||||
Exec "INSERT mixed_pilot.installed_module(object_name,definition_hash) SELECT QUOTENAME(OBJECT_SCHEMA_NAME(object_id))+'.'+QUOTENAME(OBJECT_NAME(object_id)),HASHBYTES('SHA2_256',definition) FROM sys.sql_modules WHERE OBJECT_SCHEMA_NAME(object_id)='mixed_pilot' OR OBJECT_NAME(object_id) IN ('tr_mixed_pilot_job_guard','tr_mixed_pilot_slot_release','tr_mixed_pilot_stock_release')"
|
||||
$tx.Commit();$tx.Dispose();$tx=$null
|
||||
Write-Output 'INSTALL_OK: pilot OFF; three C lanes configured but not enabled; Legacy definitions backed up in mixed_pilot.module_backup'
|
||||
} catch {
|
||||
if($tx){try{$tx.Rollback()}catch{};$tx.Dispose();$tx=$null}
|
||||
throw
|
||||
} finally {$cn.Dispose()}
|
||||
18
scripts/mixed-pilot/mixed-pilot.cmd
Normal file
18
scripts/mixed-pilot/mixed-pilot.cmd
Normal file
@@ -0,0 +1,18 @@
|
||||
@echo off
|
||||
setlocal
|
||||
if "%~3"=="" goto usage
|
||||
if /I "%~1"=="install" (
|
||||
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0install-mixed-pilot.ps1" -Server "%~2" -Database "%~3"
|
||||
exit /b
|
||||
)
|
||||
if /I "%~1"=="enable" (
|
||||
if /I not "%~4"=="CONFIRM-FIT-ALL-PROJECTS" goto usage
|
||||
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0control-mixed-pilot.ps1" -Server "%~2" -Database "%~3" -Mode enable -ConfirmPhysicalFit -AllProjectsMayMix
|
||||
exit /b
|
||||
)
|
||||
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0control-mixed-pilot.ps1" -Server "%~2" -Database "%~3" -Mode "%~1"
|
||||
exit /b
|
||||
:usage
|
||||
echo Usage: mixed-pilot.cmd install^|status^|disable^|uninstall "lpc:." "FT546-wms"
|
||||
echo Enable: mixed-pilot.cmd enable "lpc:." "FT546-wms" CONFIRM-FIT-ALL-PROJECTS
|
||||
exit /b 2
|
||||
256
scripts/mixed-pilot/objects.sql
Normal file
256
scripts/mixed-pilot/objects.sql
Normal file
@@ -0,0 +1,256 @@
|
||||
CREATE TABLE mixed_pilot.control (
|
||||
id int NOT NULL PRIMARY KEY CHECK(id=1),
|
||||
version varchar(30) NOT NULL,
|
||||
installed bit NOT NULL DEFAULT 1,
|
||||
enabled bit NOT NULL DEFAULT 0,
|
||||
all_projects_may_mix bit NOT NULL DEFAULT 0,
|
||||
min_free_e_slots int NOT NULL DEFAULT 2 CHECK(min_free_e_slots>=2)
|
||||
);
|
||||
INSERT mixed_pilot.control(id,version,enabled,all_projects_may_mix) VALUES(1,'20260910-1',0,0);
|
||||
CREATE TABLE mixed_pilot.lane (
|
||||
warehouse_id varchar(80) NOT NULL,
|
||||
lane_code varchar(80) NOT NULL,
|
||||
enabled bit NOT NULL DEFAULT 0,
|
||||
physical_fit_confirmed bit NOT NULL DEFAULT 0,
|
||||
protected bit NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(warehouse_id,lane_code)
|
||||
);
|
||||
INSERT mixed_pilot.lane(warehouse_id,lane_code) VALUES('01','C1203'),('01','C0204'),('01','C0205');
|
||||
CREATE TABLE mixed_pilot.module_backup (
|
||||
object_name sysname NOT NULL PRIMARY KEY,
|
||||
original_definition nvarchar(max) NOT NULL,
|
||||
original_hash varbinary(32) NOT NULL,
|
||||
installed_hash varbinary(32) NULL,
|
||||
ansi_nulls bit NOT NULL,
|
||||
quoted_identifier bit NOT NULL
|
||||
);
|
||||
CREATE TABLE mixed_pilot.installed_module (
|
||||
object_name nvarchar(260) NOT NULL PRIMARY KEY,
|
||||
definition_hash varbinary(32) NOT NULL
|
||||
);
|
||||
CREATE TABLE mixed_pilot.admission (
|
||||
job_id varchar(255) NOT NULL PRIMARY KEY,
|
||||
lane_code varchar(80) NOT NULL,
|
||||
cargo_space_id varchar(80) NOT NULL,
|
||||
tray_code varchar(255) NOT NULL,
|
||||
resident_project varchar(255) NOT NULL,
|
||||
guest_project varchar(255) NOT NULL,
|
||||
created_at datetime2 NOT NULL DEFAULT SYSUTCDATETIME()
|
||||
);
|
||||
CREATE TABLE mixed_pilot.move_audit (
|
||||
job_id varchar(255) NOT NULL PRIMARY KEY,
|
||||
source_slot varchar(255) NOT NULL,
|
||||
target_slot varchar(255) NOT NULL,
|
||||
tray_code varchar(255) NOT NULL,
|
||||
completed_at datetime2 NULL
|
||||
);
|
||||
CREATE TABLE mixed_pilot.dispatch_hold (
|
||||
job_id varchar(255) NOT NULL PRIMARY KEY,
|
||||
created_at datetime2 NOT NULL DEFAULT SYSUTCDATETIME(),
|
||||
released_at datetime2 NULL
|
||||
);
|
||||
GO
|
||||
-- A source position is inaccessible while ANY outer position has cargo or a live task.
|
||||
CREATE VIEW mixed_pilot.blocked_jobs AS
|
||||
SELECT j.job_id
|
||||
FROM dbo.mydo_job j
|
||||
JOIN dbo.bas_cargo_space s ON s.cargo_space_id=j.cargo_space_id
|
||||
JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no AND l.protected=1
|
||||
WHERE j.in_or_out>=20 AND EXISTS (
|
||||
SELECT 1 FROM dbo.bas_cargo_space b
|
||||
WHERE b.warehouse_id=s.warehouse_id AND b.group_coding_no=s.group_coding_no
|
||||
AND TRY_CONVERT(int,b.group_coding)>TRY_CONVERT(int,s.group_coding)
|
||||
AND (ISNULL(b.status,'')<>'1' OR ISNULL(b.locked,'')<>'N' OR NULLIF(b.tray_code,'') IS NOT NULL
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=b.cargo_space_id)
|
||||
OR EXISTS(SELECT 1 FROM dbo.mydo_job x WHERE x.cargo_space_id=b.cargo_space_id OR x.target_cargo_space_id=b.cargo_space_id))
|
||||
);
|
||||
GO
|
||||
CREATE PROCEDURE mixed_pilot.release_ready_jobs AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
-- Clearing only a slot flag is insufficient: inventory and front tasks must also be gone.
|
||||
UPDATE j SET status='1'
|
||||
FROM dbo.mydo_job j JOIN mixed_pilot.dispatch_hold h ON h.job_id=j.job_id AND h.released_at IS NULL
|
||||
JOIN dbo.bas_cargo_space s ON s.cargo_space_id=j.cargo_space_id
|
||||
WHERE j.status='8' AND s.status='4' AND s.tray_code=j.tray_code
|
||||
AND EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=s.cargo_space_id AND x.tray_code=j.tray_code)
|
||||
AND NOT EXISTS(SELECT 1 FROM mixed_pilot.blocked_jobs b WHERE b.job_id=j.job_id);
|
||||
UPDATE h SET released_at=SYSUTCDATETIME()
|
||||
FROM mixed_pilot.dispatch_hold h
|
||||
WHERE h.released_at IS NULL AND NOT EXISTS(SELECT 1 FROM dbo.mydo_job j WHERE j.job_id=h.job_id AND j.status='8');
|
||||
END;
|
||||
GO
|
||||
CREATE TRIGGER dbo.tr_mixed_pilot_job_guard ON dbo.mydo_job AFTER INSERT,UPDATE,DELETE AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
IF TRIGGER_NESTLEVEL(OBJECT_ID('dbo.tr_mixed_pilot_job_guard'))>1 RETURN;
|
||||
IF NOT EXISTS(SELECT 1 FROM mixed_pilot.lane WHERE enabled=1 OR protected=1) RETURN;
|
||||
-- Coordinate with inbound reservation before checking whether a lane has just become mixed.
|
||||
DECLARE @locked int;
|
||||
SELECT @locked=COUNT(*) FROM dbo.bas_cargo_space s WITH(UPDLOCK,HOLDLOCK)
|
||||
JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no
|
||||
WHERE (l.enabled=1 OR l.protected=1) AND EXISTS(
|
||||
SELECT 1 FROM inserted i JOIN dbo.bas_cargo_space origin ON origin.cargo_space_id=i.cargo_space_id
|
||||
WHERE origin.warehouse_id=s.warehouse_id AND origin.group_coding_no=s.group_coding_no AND i.in_or_out>=20);
|
||||
IF EXISTS(SELECT 1 FROM inserted i JOIN mixed_pilot.blocked_jobs blocked ON blocked.job_id=i.job_id
|
||||
JOIN dbo.bas_cargo_space s ON s.cargo_space_id=i.cargo_space_id
|
||||
JOIN dbo.bas_cargo_space front ON front.warehouse_id=s.warehouse_id AND front.group_coding_no=s.group_coding_no
|
||||
AND TRY_CONVERT(int,front.group_coding)>TRY_CONVERT(int,s.group_coding)
|
||||
WHERE i.status='1' AND NOT EXISTS(SELECT 1 FROM dbo.mydo_job j WITH(UPDLOCK,HOLDLOCK)
|
||||
WHERE j.cargo_space_id=front.cargo_space_id AND j.tray_code=front.tray_code AND j.in_or_out>=20 AND j.status IN('1','3')))
|
||||
THROW 51126,N'外侧尚未安排腾挪,请使用自动分配出库;外侧正在入库时请稍后重试。',1;
|
||||
INSERT mixed_pilot.dispatch_hold(job_id)
|
||||
SELECT DISTINCT i.job_id FROM inserted i JOIN mixed_pilot.blocked_jobs b ON b.job_id=i.job_id
|
||||
WHERE i.status='1' AND NOT EXISTS(SELECT 1 FROM mixed_pilot.dispatch_hold h WITH(UPDLOCK,HOLDLOCK) WHERE h.job_id=i.job_id);
|
||||
UPDATE j SET status='8'
|
||||
FROM dbo.mydo_job j JOIN inserted i ON i.job_id=j.job_id
|
||||
JOIN mixed_pilot.blocked_jobs b ON b.job_id=j.job_id
|
||||
WHERE j.status='1';
|
||||
EXEC mixed_pilot.release_ready_jobs;
|
||||
END;
|
||||
GO
|
||||
CREATE TRIGGER dbo.tr_mixed_pilot_slot_release ON dbo.bas_cargo_space AFTER UPDATE AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
IF NOT EXISTS(SELECT 1 FROM inserted i JOIN mixed_pilot.lane l ON l.warehouse_id=i.warehouse_id AND l.lane_code=i.group_coding_no AND l.protected=1) RETURN;
|
||||
EXEC mixed_pilot.release_ready_jobs;
|
||||
END;
|
||||
GO
|
||||
CREATE TRIGGER dbo.tr_mixed_pilot_stock_release ON dbo.bas_stock AFTER UPDATE,DELETE AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
IF NOT EXISTS(SELECT 1 FROM deleted d JOIN dbo.bas_cargo_space s ON s.cargo_space_id=d.cargo_space_id
|
||||
JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no AND l.protected=1) RETURN;
|
||||
EXEC mixed_pilot.release_ready_jobs;
|
||||
END;
|
||||
GO
|
||||
CREATE PROCEDURE mixed_pilot.check_inbound
|
||||
@job_id varchar(255),@slot varchar(80)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
IF @@TRANCOUNT=0 OR ISNULL(APPLOCK_MODE('public','FT546.mixed_pilot.inbound','Transaction'),'NoLock')<>'Exclusive'
|
||||
THROW 51129,N'试点入库校验必须由受保护的分配入口调用。',1;
|
||||
DECLARE @lane varchar(80),@warehouse varchar(80),@depth int,@project varchar(255),@tray varchar(255),@resident varchar(255),@rear varchar(80),@floor int;
|
||||
SELECT @lane=s.group_coding_no,@warehouse=s.warehouse_id,@depth=TRY_CONVERT(int,s.group_coding),@floor=s.the_floor
|
||||
FROM dbo.bas_cargo_space s JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no
|
||||
WHERE s.cargo_space_id=@slot AND (l.enabled=1 OR l.protected=1);
|
||||
IF @lane IS NULL RETURN;
|
||||
SELECT @project=line_id,@tray=tray_code FROM dbo.mydo_job WHERE job_id=@job_id AND warehouse_id=@warehouse AND in_or_out<20;
|
||||
IF @project IS NULL OR @project IN ('','999') OR @tray IS NULL
|
||||
THROW 51101,N'试点货位的任务项目或仓库不明确,请核对入库任务。',1;
|
||||
IF (SELECT COUNT(*) FROM dbo.bas_cargo_space WHERE warehouse_id=@warehouse AND group_coding_no=@lane)<>2
|
||||
OR (SELECT COUNT(*) FROM dbo.bas_cargo_space WHERE warehouse_id=@warehouse AND group_coding_no=@lane AND TRY_CONVERT(int,group_coding)=1)<>1
|
||||
OR (SELECT COUNT(*) FROM dbo.bas_cargo_space WHERE warehouse_id=@warehouse AND group_coding_no=@lane AND TRY_CONVERT(int,group_coding)=2)<>1
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE warehouse_id=@warehouse AND group_coding_no=@lane AND (this_cargo_area<>'C' OR TRY_CONVERT(int,group_coding) NOT IN(1,2) OR TRY_CONVERT(int,group_coding) IS NULL))
|
||||
THROW 51102,N'试点仅支持已指定的双深C区通道。',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE cargo_space_id=@slot AND status='1' AND locked='N' AND NULLIF(tray_code,'') IS NULL)
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_stock WHERE cargo_space_id=@slot)
|
||||
OR EXISTS(SELECT 1 FROM dbo.mydo_job j JOIN dbo.bas_cargo_space s ON s.cargo_space_id=j.cargo_space_id OR s.cargo_space_id=j.target_cargo_space_id
|
||||
WHERE s.warehouse_id=@warehouse AND s.group_coding_no=@lane AND j.job_id<>@job_id)
|
||||
THROW 51103,N'试点通道已有库存或在途任务,请稍后重试。',1;
|
||||
IF EXISTS(SELECT 1 FROM dbo.bas_cargo_space s WHERE s.warehouse_id=@warehouse AND s.group_coding_no=@lane
|
||||
AND (s.locked<>'N' OR s.status NOT IN('1','2')))
|
||||
THROW 51104,N'试点通道正在作业或被锁定,请稍后重试。',1;
|
||||
IF @depth=1 AND EXISTS(SELECT 1 FROM dbo.bas_cargo_space s WHERE s.warehouse_id=@warehouse AND s.group_coding_no=@lane AND TRY_CONVERT(int,s.group_coding)=2
|
||||
AND (s.status<>'1' OR NULLIF(s.tray_code,'') IS NOT NULL OR EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=s.cargo_space_id)))
|
||||
THROW 51105,N'外侧尚有托盘,不能向通道内侧补货。',1;
|
||||
SELECT @rear=cargo_space_id FROM dbo.bas_cargo_space WHERE warehouse_id=@warehouse AND group_coding_no=@lane AND TRY_CONVERT(int,group_coding)=1;
|
||||
IF @depth<>2 OR NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE cargo_space_id=@rear AND status='2') RETURN;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job_detail WHERE job_id=@job_id)
|
||||
OR EXISTS(SELECT 1 FROM dbo.mydo_job_detail WHERE job_id=@job_id AND (NULLIF(spare4,'') IS NULL OR spare4<>@project))
|
||||
OR (SELECT COUNT(DISTINCT spare4) FROM dbo.bas_stock WHERE cargo_space_id=@rear)<>1
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_stock x JOIN dbo.bas_cargo_space s ON s.cargo_space_id=x.cargo_space_id
|
||||
WHERE x.cargo_space_id=@rear AND (NULLIF(x.spare4,'') IS NULL OR x.tray_code<>s.tray_code OR x.tray_code IS NULL OR ISNULL(x.out_quantity,0)<>0))
|
||||
THROW 51106,N'托盘项目明细不一致或库存已分配,不能参与混放。',1;
|
||||
SELECT TOP(1) @resident=spare4 FROM dbo.bas_stock WHERE cargo_space_id=@rear;
|
||||
IF @resident=@project RETURN;
|
||||
IF @resident='999' OR NOT EXISTS(SELECT 1 FROM mixed_pilot.control WHERE id=1 AND enabled=1 AND all_projects_may_mix=1)
|
||||
OR NOT EXISTS(SELECT 1 FROM mixed_pilot.lane WHERE warehouse_id=@warehouse AND lane_code=@lane AND enabled=1 AND physical_fit_confirmed=1)
|
||||
THROW 51107,N'该通道未开放混放或尚未确认托盘适配。',1;
|
||||
IF (SELECT COUNT(*) FROM dbo.bas_cargo_space e WHERE e.warehouse_id=@warehouse AND e.the_floor=@floor AND e.this_cargo_area='E'
|
||||
AND e.status='1' AND e.locked='N' AND NULLIF(e.tray_code,'') IS NULL
|
||||
AND NOT EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=e.cargo_space_id)
|
||||
AND NOT EXISTS(SELECT 1 FROM dbo.mydo_job x WHERE x.cargo_space_id=e.cargo_space_id OR x.target_cargo_space_id=e.cargo_space_id)
|
||||
AND NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space b WHERE b.warehouse_id=e.warehouse_id AND b.group_coding_no=e.group_coding_no
|
||||
AND TRY_CONVERT(int,b.group_coding)>TRY_CONVERT(int,e.group_coding)
|
||||
AND (b.status<>'1' OR b.locked<>'N' OR NULLIF(b.tray_code,'') IS NOT NULL
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=b.cargo_space_id)
|
||||
OR EXISTS(SELECT 1 FROM dbo.mydo_job x WHERE x.cargo_space_id=b.cargo_space_id OR x.target_cargo_space_id=b.cargo_space_id))))
|
||||
<(SELECT min_free_e_slots FROM mixed_pilot.control WHERE id=1)
|
||||
THROW 51108,N'同层可用移库空间不足,暂停新增混放。',1;
|
||||
UPDATE mixed_pilot.lane SET protected=1 WHERE warehouse_id=@warehouse AND lane_code=@lane;
|
||||
IF EXISTS(SELECT 1 FROM mixed_pilot.admission WHERE job_id=@job_id AND (cargo_space_id<>@slot OR tray_code<>@tray OR resident_project<>@resident OR guest_project<>@project))
|
||||
THROW 51127,N'该任务已有不同的混放记录,请核对后重新创建任务。',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM mixed_pilot.admission WHERE job_id=@job_id)
|
||||
INSERT mixed_pilot.admission(job_id,lane_code,cargo_space_id,tray_code,resident_project,guest_project)
|
||||
VALUES(@job_id,@lane,@slot,@tray,@resident,@project);
|
||||
END;
|
||||
GO
|
||||
CREATE PROCEDURE mixed_pilot.try_fallback
|
||||
@job_id varchar(255),@slot varchar(80) OUTPUT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET @slot=NULL;
|
||||
IF NOT EXISTS(SELECT 1 FROM mixed_pilot.control WHERE id=1 AND enabled=1 AND all_projects_may_mix=1) RETURN;
|
||||
DECLARE @candidate varchar(80),@last_error nvarchar(2048);
|
||||
DECLARE candidates CURSOR LOCAL FAST_FORWARD FOR
|
||||
SELECT s.cargo_space_id FROM dbo.bas_cargo_space s
|
||||
JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no
|
||||
JOIN dbo.mydo_job j ON j.job_id=@job_id AND j.warehouse_id=s.warehouse_id
|
||||
WHERE l.enabled=1 AND l.physical_fit_confirmed=1 AND s.this_cargo_area='C' AND TRY_CONVERT(int,s.group_coding)=2
|
||||
AND s.status='1' AND s.locked='N' AND NULLIF(s.tray_code,'') IS NULL
|
||||
AND EXISTS(SELECT 1 FROM dbo.bas_cargo_space r WHERE r.warehouse_id=s.warehouse_id AND r.group_coding_no=s.group_coding_no AND TRY_CONVERT(int,r.group_coding)=1 AND r.status='2')
|
||||
AND NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space busy WHERE busy.warehouse_id=s.warehouse_id AND busy.the_floor=s.the_floor AND busy.status='3')
|
||||
ORDER BY s.the_floor,s.group_coding_no;
|
||||
OPEN candidates; FETCH NEXT FROM candidates INTO @candidate;
|
||||
WHILE @@FETCH_STATUS=0
|
||||
BEGIN
|
||||
BEGIN TRY
|
||||
EXEC mixed_pilot.check_inbound @job_id,@candidate;
|
||||
SET @slot=@candidate;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF ERROR_NUMBER() NOT BETWEEN 51101 AND 51108 BEGIN CLOSE candidates; DEALLOCATE candidates; THROW; END;
|
||||
SET @last_error=ERROR_MESSAGE();
|
||||
END CATCH;
|
||||
IF @slot IS NOT NULL BREAK;
|
||||
FETCH NEXT FROM candidates INTO @candidate;
|
||||
END;
|
||||
CLOSE candidates; DEALLOCATE candidates;
|
||||
IF @slot IS NULL AND @last_error IS NOT NULL THROW 51128,@last_error,1;
|
||||
END;
|
||||
GO
|
||||
CREATE PROCEDURE mixed_pilot.check_move
|
||||
@source varchar(255),@target varchar(255),@tray varchar(255)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
DECLARE @floor int,@warehouse varchar(80),@src_lane varchar(80),@src_depth int,@dst_lane varchar(80),@dst_depth int,@dummy int;
|
||||
-- Lock both positions before the Legacy routine changes source/target or creates a task.
|
||||
SELECT @dummy=COUNT(*) FROM dbo.bas_cargo_space WITH(UPDLOCK,HOLDLOCK) WHERE cargo_space_id IN(@source,@target);
|
||||
SELECT @floor=the_floor,@warehouse=warehouse_id,@src_lane=group_coding_no,@src_depth=TRY_CONVERT(int,group_coding)
|
||||
FROM dbo.bas_cargo_space WHERE cargo_space_id=@source AND status='2' AND locked='N' AND tray_code=@tray;
|
||||
IF @floor IS NULL OR @source=@target OR NULLIF(@tray,'') IS NULL
|
||||
THROW 51111,N'移库起点、托盘或货位状态不一致,未创建移库任务。',1;
|
||||
IF EXISTS(SELECT 1 FROM dbo.mydo_job WITH(UPDLOCK,HOLDLOCK) WHERE tray_code=@tray OR cargo_space_id IN(@source,@target) OR target_cargo_space_id IN(@source,@target))
|
||||
THROW 51112,N'移库起终点已有任务,未重复创建移库。',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.bas_stock WITH(UPDLOCK,HOLDLOCK) WHERE cargo_space_id=@source AND tray_code=@tray)
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_stock WITH(UPDLOCK,HOLDLOCK) WHERE cargo_space_id=@source AND (tray_code<>@tray OR tray_code IS NULL OR ISNULL(out_quantity,0)<>0))
|
||||
THROW 51113,N'移库源库存不一致或已分配,未创建移库任务。',1;
|
||||
SELECT @dst_lane=group_coding_no,@dst_depth=TRY_CONVERT(int,group_coding)
|
||||
FROM dbo.bas_cargo_space WHERE cargo_space_id=@target AND warehouse_id=@warehouse AND the_floor=@floor
|
||||
AND this_cargo_area='E' AND status='1' AND locked='N' AND NULLIF(tray_code,'') IS NULL;
|
||||
IF @dst_lane IS NULL OR @dst_depth IS NULL OR EXISTS(SELECT 1 FROM dbo.bas_stock WITH(UPDLOCK,HOLDLOCK) WHERE cargo_space_id=@target)
|
||||
THROW 51114,N'同层移库目标不可用,未创建移库任务。',1;
|
||||
SELECT @dummy=COUNT(*) FROM dbo.bas_cargo_space WITH(UPDLOCK,HOLDLOCK)
|
||||
WHERE warehouse_id=@warehouse AND group_coding_no IN(@src_lane,@dst_lane);
|
||||
IF EXISTS(SELECT 1 FROM dbo.bas_cargo_space b WITH(UPDLOCK,HOLDLOCK) WHERE b.warehouse_id=@warehouse
|
||||
AND ((b.group_coding_no=@src_lane AND TRY_CONVERT(int,b.group_coding)>@src_depth)
|
||||
OR (b.group_coding_no=@dst_lane AND TRY_CONVERT(int,b.group_coding)>@dst_depth))
|
||||
AND (b.status<>'1' OR b.locked<>'N' OR NULLIF(b.tray_code,'') IS NOT NULL
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_stock x WITH(UPDLOCK,HOLDLOCK) WHERE x.cargo_space_id=b.cargo_space_id)
|
||||
OR EXISTS(SELECT 1 FROM dbo.mydo_job x WITH(UPDLOCK,HOLDLOCK) WHERE x.cargo_space_id=b.cargo_space_id OR x.target_cargo_space_id=b.cargo_space_id)))
|
||||
THROW 51115,N'移库路径外侧被占用,未创建移库任务。',1;
|
||||
END;
|
||||
121
scripts/mixed-pilot/test-mixed-pilot.ps1
Normal file
121
scripts/mixed-pilot/test-mixed-pilot.ps1
Normal file
@@ -0,0 +1,121 @@
|
||||
param(
|
||||
[string]$Server = 'lpc:.',
|
||||
[Parameter(Mandatory=$true)][string]$FixtureJson,
|
||||
[switch]$Baseline
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
|
||||
# Synthetic rows only. Never run fixtures in a production database.
|
||||
$testDb = 'FT546WMS_MixedPilot_Test_' + [Guid]::NewGuid().ToString('N')
|
||||
$cn = [Data.SqlClient.SqlConnection]::new("Server=$Server;Database=master;Integrated Security=True;Connect Timeout=8;Application Name=FT546MixedPilotTests")
|
||||
$cn.Open()
|
||||
function Exec([string]$sql) {
|
||||
$c=$cn.CreateCommand(); $c.CommandTimeout=15
|
||||
$c.CommandText=$sql
|
||||
try { [void]$c.ExecuteNonQuery() } finally {$c.Dispose()}
|
||||
}
|
||||
function Scalar([string]$sql) {
|
||||
$c=$cn.CreateCommand(); $c.CommandTimeout=15; $c.CommandText=$sql
|
||||
try { $c.ExecuteScalar() } finally {$c.Dispose()}
|
||||
}
|
||||
function BatchFile([string]$file) {
|
||||
foreach($batch in ([IO.File]::ReadAllText($file) -split '(?im)^\s*GO\s*\r?$')) {
|
||||
if($batch.Trim()) { Exec $batch }
|
||||
}
|
||||
}
|
||||
function Assert([bool]$ok,[string]$name) { if(-not $ok){throw "FAIL: $name"}; Write-Output "PASS: $name" }
|
||||
$created=$false
|
||||
try {
|
||||
Exec "CREATE DATABASE [$testDb] ON PRIMARY (NAME=N'$testDb', FILENAME=N'$(Scalar "SELECT CONVERT(nvarchar(4000),SERVERPROPERTY('InstanceDefaultDataPath'))")$testDb.mdf',SIZE=8MB,MAXSIZE=64MB,FILEGROWTH=8MB) LOG ON (NAME=N'${testDb}_log', FILENAME=N'$(Scalar "SELECT CONVERT(nvarchar(4000),SERVERPROPERTY('InstanceDefaultLogPath'))")${testDb}_log.ldf',SIZE=8MB,MAXSIZE=64MB,FILEGROWTH=8MB)"
|
||||
$created=$true; $cn.ChangeDatabase($testDb)
|
||||
Exec 'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET DEADLOCK_PRIORITY LOW;'
|
||||
$fixture=Get-Content -LiteralPath $FixtureJson -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$allowed=@('bas_cargo_space','bas_stock','mydo_job','mydo_job_detail','mydo_job_finish','bas_led','move_msg','out_stockout','out_stockout_detail')
|
||||
foreach($table in ($fixture.columns|Where-Object table_name -in $allowed|Group-Object table_name)) {
|
||||
$defs=@(foreach($c in $table.Group){
|
||||
$type=$c.type_name
|
||||
if($type -in @('varchar','char','nvarchar','nchar','varbinary','binary')) {
|
||||
$size=if($c.max_length -eq -1){'max'}elseif($type.StartsWith('n')){[string]($c.max_length/2)}else{[string]$c.max_length}
|
||||
$type+="($size)"
|
||||
} elseif($type -in @('decimal','numeric')) {$type+="($($c.precision),$($c.scale))"}
|
||||
# Nullability is relaxed for concise synthetic fixtures, types/order match site.
|
||||
"[$($c.column_name)] $type NULL"
|
||||
})
|
||||
Exec "CREATE TABLE dbo.[$($table.Name)] ($($defs -join ','))"
|
||||
}
|
||||
foreach($p in $fixture.wms_objects|Where-Object name -in @('cargo_space_group_count','proc_allot_space','proc_yk_job_add','proc_yk_complete','proc_out_auto_allocation','proc_out_hand_allocation')) {Exec $p.definition}
|
||||
Exec "CREATE TABLE dbo.bas_roadway(roadway_id varchar(80),out_locked varchar(10),out_locked2 varchar(10))"
|
||||
Exec "CREATE PROCEDURE dbo.util_serial_job @kind varchar(10),@id varchar(255) OUTPUT AS SET @id=CONVERT(varchar(36),NEWID())"
|
||||
Exec "CREATE SEQUENCE dbo.test_task_seq AS int START WITH 900001"
|
||||
Exec "CREATE PROCEDURE dbo.util_serial_task @kind varchar(10),@id int OUTPUT AS SET @id=NEXT VALUE FOR dbo.test_task_seq"
|
||||
if(-not $Baseline){ & "$PSScriptRoot/install-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase }
|
||||
BatchFile "$PSScriptRoot/tests/seed.sql"
|
||||
if($Baseline){
|
||||
$result=Scalar "DECLARE @r int,@m varchar(255); EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT; SELECT @r"
|
||||
Assert ($result -eq 10) 'new project gets designated front slot when Legacy has no position'
|
||||
} else {
|
||||
& "$PSScriptRoot/control-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase -Mode enable -ConfirmPhysicalFit -AllProjectsMayMix
|
||||
Assert ((Scalar 'SELECT COUNT(*) FROM mixed_pilot.lane WHERE enabled=1') -eq 3) 'enable command opens exactly three whole channels'
|
||||
& "$PSScriptRoot/control-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase -Mode disable
|
||||
$refused=$false
|
||||
try { & "$PSScriptRoot/control-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase -Mode uninstall } catch {$refused=$_.Exception.Message -like '*not drained*'}
|
||||
Assert $refused 'uninstall refuses occupied trial channels'
|
||||
BatchFile "$PSScriptRoot/tests/behavior.sql"
|
||||
Write-Output 'PASS: mixed-pilot SQL behavior suite'
|
||||
Exec 'DELETE dbo.mydo_job; DELETE dbo.mydo_job_detail; DELETE dbo.bas_stock; DELETE dbo.bas_cargo_space; DELETE mixed_pilot.dispatch_hold; DELETE mixed_pilot.move_audit; DELETE mixed_pilot.admission;'
|
||||
BatchFile "$PSScriptRoot/tests/seed.sql"
|
||||
BatchFile "$PSScriptRoot/tests/legacy-outbound.sql"
|
||||
Write-Output 'PASS: actual site Legacy outbound procedure generates blocker relocation and holds rear outbound'
|
||||
Exec 'DELETE dbo.mydo_job; DELETE dbo.mydo_job_detail; DELETE dbo.bas_stock; DELETE dbo.bas_cargo_space; DELETE mixed_pilot.dispatch_hold; DELETE mixed_pilot.move_audit; DELETE mixed_pilot.admission;'
|
||||
BatchFile "$PSScriptRoot/tests/seed.sql"
|
||||
Exec "UPDATE mixed_pilot.control SET enabled=1,all_projects_may_mix=1; UPDATE mixed_pilot.lane SET enabled=CASE WHEN lane_code='C1203' THEN 1 ELSE 0 END,physical_fit_confirmed=1; INSERT dbo.mydo_job(job_id,tray_code,warehouse_id,line_id,in_or_out,status,fault_flag) VALUES('IN-RACE','RACE-TRAY','01','RACE',1,'1','0'); INSERT dbo.mydo_job_detail(job_id,spare4,spare1,spare2,spare3,in_quantity) VALUES('IN-RACE','RACE','X','Y','Z',1)"
|
||||
$raceCn=[Data.SqlClient.SqlConnection]::new("Server=$Server;Database=$testDb;Integrated Security=True;Connect Timeout=8;Application Name=FT546MixedPilotRaceTest")
|
||||
$raceCn.Open()
|
||||
try {
|
||||
Exec 'BEGIN TRANSACTION;'
|
||||
Exec "DECLARE @r int,@m varchar(255); EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT; IF @r<>10 THROW 51950,'First racing allocation failed',1;"
|
||||
$raceCmd=$raceCn.CreateCommand();$raceCmd.CommandTimeout=10
|
||||
$raceCmd.CommandText="DECLARE @r int,@m varchar(255); EXEC dbo.proc_allot_space 'RACE-TRAY','TEST',@r OUTPUT,@m OUTPUT; SELECT @r;"
|
||||
$pending=$raceCmd.ExecuteScalarAsync()
|
||||
# Hold the first reservation across the second request's lock timeout.
|
||||
$second=$pending.GetAwaiter().GetResult()
|
||||
Assert ($second -ne 10) 'concurrent request cannot allocate a reserved pilot slot'
|
||||
Exec 'COMMIT TRANSACTION;'
|
||||
Assert ((Scalar "SELECT COUNT(*) FROM dbo.mydo_job WHERE cargo_space_id='120703'") -eq 1) 'only one inbound task owns the pilot slot'
|
||||
} finally {
|
||||
if((Scalar 'SELECT @@TRANCOUNT') -gt 0){Exec 'ROLLBACK TRANSACTION;'}
|
||||
if($raceCmd){$raceCmd.Dispose()};$raceCn.Dispose()
|
||||
}
|
||||
# Re-running install must preserve runtime switches and history.
|
||||
Exec 'UPDATE mixed_pilot.control SET enabled=1 WHERE id=1'
|
||||
& "$PSScriptRoot/install-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase
|
||||
Assert ((Scalar 'SELECT enabled FROM mixed_pilot.control WHERE id=1') -eq 1) 'repeat install preserves switch'
|
||||
$releaseDefinition=Scalar "SELECT OBJECT_DEFINITION(OBJECT_ID('mixed_pilot.release_ready_jobs'))"
|
||||
Exec 'ALTER PROCEDURE mixed_pilot.release_ready_jobs AS RETURN;'
|
||||
$driftRejected=$false
|
||||
try { & "$PSScriptRoot/install-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase } catch {$driftRejected=$_.Exception.Message -like '*drift*'}
|
||||
Assert $driftRejected 'repeat install detects altered pilot helpers'
|
||||
Exec 'DROP PROCEDURE mixed_pilot.release_ready_jobs;'
|
||||
Exec $releaseDefinition
|
||||
& "$PSScriptRoot/control-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase -Mode disable
|
||||
Exec 'DELETE dbo.mydo_job; DELETE dbo.bas_stock; UPDATE dbo.bas_cargo_space SET status=''1'',tray_code=NULL;'
|
||||
& "$PSScriptRoot/control-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase -Mode uninstall
|
||||
Assert ((Scalar 'SELECT installed FROM mixed_pilot.control WHERE id=1') -eq 0) 'uninstall removes runtime protection only after draining'
|
||||
foreach($p in $fixture.wms_objects|Where-Object name -in @('proc_allot_space','proc_yk_job_add','proc_yk_complete')) {
|
||||
$restored=Scalar "SELECT OBJECT_DEFINITION(OBJECT_ID('dbo.$($p.name)'))"
|
||||
# SQL Server may persist CREATE even when ALTER was submitted. Compare the full body with only that keyword normalized.
|
||||
$expected=[regex]::Replace($p.definition,'(?i)\b(?:CREATE|ALTER)\s+PROCEDURE','CREATE PROCEDURE')
|
||||
$normalized=[regex]::Replace($restored,'(?i)\b(?:CREATE|ALTER)\s+PROCEDURE','CREATE PROCEDURE')
|
||||
Assert ($normalized -ceq $expected) "uninstall restores $($p.name) body exactly"
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if($created){
|
||||
[Data.SqlClient.SqlConnection]::ClearAllPools()
|
||||
$cn.ChangeDatabase('master')
|
||||
if($testDb -notmatch '^FT546WMS_MixedPilot_Test_[a-f0-9]{32}$'){throw 'Invalid scratch database name'}
|
||||
Exec "DROP DATABASE [$testDb]"
|
||||
Write-Output "Scratch database removed: $testDb"
|
||||
}
|
||||
$cn.Dispose()
|
||||
}
|
||||
138
scripts/mixed-pilot/tests/behavior.sql
Normal file
138
scripts/mixed-pilot/tests/behavior.sql
Normal file
@@ -0,0 +1,138 @@
|
||||
SET NOCOUNT ON;
|
||||
DECLARE @r int,@m varchar(255),@job varchar(255),@n int;
|
||||
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
|
||||
IF @r<>2 OR EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='IN-NEW' AND cargo_space_id IS NOT NULL)
|
||||
THROW 51900,'Disabled pilot must preserve Legacy no-position behavior',1;
|
||||
|
||||
UPDATE mixed_pilot.control SET enabled=1,all_projects_may_mix=1 WHERE id=1;
|
||||
UPDATE mixed_pilot.lane SET enabled=1,physical_fit_confirmed=1 WHERE lane_code='C1203';
|
||||
-- An ordinary same-project position must win before mixed fallback.
|
||||
INSERT dbo.bas_cargo_space(cargo_space_id,warehouse_id,the_floor,the_platoon,the_column,group_coding_no,group_coding,this_cargo_area,status,locked,tray_code)
|
||||
VALUES('110801','01',1,11,8,'C1101','1','C','2','N','SAME'),('110701','01',1,11,7,'C1101','2','C','1','N',NULL);
|
||||
INSERT dbo.bas_stock(stock_id,cargo_space_id,tray_code,spare4,spare1,spare2,spare3,quantity,out_quantity)
|
||||
VALUES('SAME-S','110801','SAME','NEW','D','E','F',1,0);
|
||||
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
|
||||
IF @r<>10 OR NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='IN-NEW' AND cargo_space_id='110701')
|
||||
THROW 51901,'Normal Legacy allocation must have priority',1;
|
||||
UPDATE dbo.mydo_job SET cargo_space_id=NULL,status='1' WHERE job_id='IN-NEW';
|
||||
DELETE dbo.bas_stock WHERE stock_id='SAME-S';
|
||||
DELETE dbo.bas_cargo_space WHERE group_coding_no='C1101';
|
||||
|
||||
-- No physical confirmation, missing E capacity, ambiguity and common-project input fail closed.
|
||||
UPDATE mixed_pilot.lane SET physical_fit_confirmed=0 WHERE lane_code='C1203';
|
||||
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
|
||||
IF @r=10 THROW 51902,'Unconfirmed physical fit must not admit mixed pallet',1;
|
||||
UPDATE mixed_pilot.lane SET physical_fit_confirmed=1 WHERE lane_code='C1203';
|
||||
UPDATE dbo.bas_cargo_space SET locked='Y' WHERE this_cargo_area='E';
|
||||
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
|
||||
IF @r=10 THROW 51903,'No same-floor E capacity must not admit mixed pallet',1;
|
||||
UPDATE dbo.bas_cargo_space SET locked='N' WHERE this_cargo_area='E';
|
||||
UPDATE dbo.mydo_job_detail SET spare4='WRONG' WHERE job_id='IN-NEW';
|
||||
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
|
||||
IF @r=10 THROW 51904,'Header/detail project mismatch must be rejected',1;
|
||||
UPDATE dbo.mydo_job_detail SET spare4='NEW' WHERE job_id='IN-NEW';
|
||||
|
||||
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
|
||||
IF @r<>10 OR NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='IN-NEW' AND cargo_space_id='120703' AND status='3')
|
||||
THROW 51905,'Fallback must reserve only the designated outer position',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE cargo_space_id='120703' AND status='3')
|
||||
THROW 51906,'Fallback must reserve slot and job together',1;
|
||||
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
|
||||
IF @r<>10 OR (SELECT COUNT(*) FROM mixed_pilot.admission)<>1
|
||||
THROW 51907,'Repeated allocation must return same reservation without a second admission',1;
|
||||
|
||||
INSERT dbo.mydo_job(job_id,tray_code,warehouse_id,line_id,in_or_out,status,execution_phase,fault_flag)
|
||||
VALUES('IN-THIRD','THIRD-TRAY','01','THIRD',1,'1','0','0');
|
||||
INSERT dbo.mydo_job_detail(job_detail_id,job_id,spare4,spare1,spare2,spare3,in_quantity)
|
||||
VALUES('IN-THIRD-1','IN-THIRD','THIRD','X','Y','Z',1);
|
||||
EXEC dbo.proc_allot_space 'THIRD-TRAY','TEST',@r OUTPUT,@m OUTPUT;
|
||||
IF @r=10 THROW 51908,'A third project or an already reserved front slot must be rejected',1;
|
||||
|
||||
-- Simulate a confirmed physical inbound completion (no WCS or HTTP is contacted).
|
||||
INSERT dbo.bas_stock(stock_id,cargo_space_id,tray_code,spare4,spare1,spare2,spare3,quantity,out_quantity)
|
||||
VALUES('GUEST-S','120703','NEW-TRAY','NEW','D','E','F',1,0);
|
||||
UPDATE dbo.bas_cargo_space SET status='2',tray_code='NEW-TRAY' WHERE cargo_space_id='120703';
|
||||
DELETE dbo.mydo_job WHERE job_id='IN-NEW';
|
||||
|
||||
-- Automatic allocation creates the blocker move before the rear job.
|
||||
EXEC dbo.proc_yk_job_add '120703','130803','NEW-TRAY';
|
||||
SELECT @job=job_id FROM dbo.mydo_job WHERE in_or_out=30 AND tray_code='NEW-TRAY';
|
||||
UPDATE dbo.bas_cargo_space SET status='4' WHERE cargo_space_id='120803';
|
||||
INSERT dbo.mydo_job(job_id,tray_code,cargo_space_id,class_id,in_or_out,status,execution_phase,task_no)
|
||||
VALUES('OUT-REAR','RESIDENT','120803','3',21,'1','0',800001);
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-REAR' AND status='8')
|
||||
THROW 51909,'Blocked rear outbound must be held before the WMS scheduler can see it',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM mixed_pilot.dispatch_hold WHERE job_id='OUT-REAR' AND released_at IS NULL)
|
||||
THROW 51910,'Held job must be diagnosable',1;
|
||||
|
||||
-- Occupied or wrong-floor destinations must not create a move or change inventory.
|
||||
BEGIN TRY
|
||||
EXEC dbo.proc_yk_job_add '120703','020704','NEW-TRAY';
|
||||
THROW 51911,'Wrong-floor move was accepted',1;
|
||||
END TRY BEGIN CATCH IF ERROR_NUMBER()=51911 THROW; END CATCH;
|
||||
IF (SELECT COUNT(*) FROM dbo.mydo_job WHERE in_or_out=30)<>1
|
||||
THROW 51912,'Rejected relocation must leave no partial task',1;
|
||||
|
||||
EXEC dbo.proc_yk_job_add '120703','130803','NEW-TRAY';
|
||||
IF @job IS NULL OR NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id=@job AND status='1')
|
||||
THROW 51913,'Outer relocation must remain dispatchable',1;
|
||||
EXEC dbo.proc_yk_job_add '120703','130803','NEW-TRAY';
|
||||
IF (SELECT COUNT(*) FROM dbo.mydo_job WHERE in_or_out=30)<>1
|
||||
THROW 51914,'Repeated identical relocation must not duplicate tasks',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-REAR' AND status='8')
|
||||
THROW 51915,'Creating a relocation is not physical completion',1;
|
||||
|
||||
-- Switch OFF while pallets are mixed: no new admissions, protection must remain.
|
||||
UPDATE mixed_pilot.control SET enabled=0 WHERE id=1;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-REAR' AND status='8')
|
||||
THROW 51916,'Disabling admission must not release blocked outbound',1;
|
||||
EXEC dbo.proc_yk_complete @job,@r OUTPUT;
|
||||
IF @r<>10 OR NOT EXISTS(SELECT 1 FROM dbo.bas_stock WHERE stock_id='GUEST-S' AND cargo_space_id='130803' AND spare4='NEW')
|
||||
THROW 51917,'Move completion must preserve guest project ownership',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-REAR' AND status='1')
|
||||
THROW 51918,'Rear outbound must be released only after confirmed front clearance',1;
|
||||
SELECT @n=COUNT(*) FROM dbo.move_msg;
|
||||
EXEC dbo.proc_yk_complete @job,@r OUTPUT;
|
||||
IF @r<>10 OR (SELECT COUNT(*) FROM dbo.move_msg)<>@n
|
||||
THROW 51919,'Duplicate move completion must not change stock or emit duplicate move messages',1;
|
||||
|
||||
-- Ordinary front outbound also blocks the rear until BOTH stock and job are cleared.
|
||||
DELETE dbo.mydo_job WHERE job_id='OUT-REAR';
|
||||
UPDATE dbo.bas_stock SET cargo_space_id='120703' WHERE stock_id='GUEST-S';
|
||||
UPDATE dbo.bas_cargo_space SET status='2',tray_code='NEW-TRAY' WHERE cargo_space_id='120703';
|
||||
INSERT dbo.mydo_job(job_id,tray_code,cargo_space_id,class_id,in_or_out,status,execution_phase,task_no)
|
||||
VALUES('OUT-FRONT','NEW-TRAY','120703','3',21,'1','0',800002),('OUT-REAR2','RESIDENT','120803','3',21,'1','0',800003);
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-FRONT' AND status='1') OR NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-REAR2' AND status='8')
|
||||
THROW 51920,'Front may dispatch while blocked rear must wait',1;
|
||||
UPDATE dbo.bas_cargo_space SET status='1',tray_code=NULL WHERE cargo_space_id='120703';
|
||||
DELETE dbo.bas_stock WHERE stock_id='GUEST-S';
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-REAR2' AND status='8')
|
||||
THROW 51921,'An active front task must continue to block rear dispatch',1;
|
||||
DELETE dbo.mydo_job WHERE job_id='OUT-FRONT';
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-REAR2' AND status='1')
|
||||
THROW 51922,'Final front task deletion must release waiting rear job',1;
|
||||
|
||||
-- Unlisted lanes must not be held by the pilot triggers.
|
||||
INSERT dbo.mydo_job(job_id,tray_code,cargo_space_id,class_id,in_or_out,status,execution_phase,task_no)
|
||||
VALUES('OTHER-OUT','OTHER-RESIDENT','080801','1',21,'1','0',800004);
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OTHER-OUT' AND status='1')
|
||||
THROW 51923,'Unlisted lanes must retain Legacy dispatch behavior',1;
|
||||
-- Manual allocation must not strand a held task when no blocker move exists.
|
||||
DELETE dbo.mydo_job WHERE job_id='OUT-REAR2';
|
||||
UPDATE dbo.bas_cargo_space SET status='2',tray_code='NEW-TRAY' WHERE cargo_space_id='120703';
|
||||
INSERT dbo.bas_stock(stock_id,cargo_space_id,tray_code,spare4,quantity,out_quantity)
|
||||
VALUES('GUEST-AGAIN','120703','NEW-TRAY','NEW',1,0);
|
||||
BEGIN TRY
|
||||
INSERT dbo.mydo_job(job_id,tray_code,cargo_space_id,class_id,in_or_out,status,execution_phase,task_no)
|
||||
VALUES('MANUAL-BLOCKED','RESIDENT','120803','3',21,'1','0',800005);
|
||||
THROW 51924,'Manual blocked outbound was accepted without a blocker task',1;
|
||||
END TRY BEGIN CATCH IF ERROR_NUMBER()=51924 THROW; END CATCH;
|
||||
IF EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='MANUAL-BLOCKED')
|
||||
THROW 51925,'Rejected manual allocation must not leave a stranded held job',1;
|
||||
BEGIN TRY
|
||||
EXEC dbo.proc_yk_job_add '080801','130803','OTHER-RESIDENT';
|
||||
THROW 51926,'Unlisted source overwrote a protected relocation destination',1;
|
||||
END TRY BEGIN CATCH IF ERROR_NUMBER()=51926 THROW; END CATCH;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE cargo_space_id='130803' AND status='2' AND tray_code='NEW-TRAY')
|
||||
THROW 51927,'Rejected stale relocation target must retain existing occupancy',1;
|
||||
PRINT 'PASS: mixed-pilot sequential behavior assertions';
|
||||
29
scripts/mixed-pilot/tests/legacy-outbound.sql
Normal file
29
scripts/mixed-pilot/tests/legacy-outbound.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
SET NOCOUNT ON;
|
||||
UPDATE mixed_pilot.control SET enabled=1,all_projects_may_mix=1;
|
||||
UPDATE mixed_pilot.lane SET enabled=CASE WHEN lane_code='C1203' THEN 1 ELSE 0 END,protected=1,physical_fit_confirmed=1;
|
||||
DECLARE @r int,@m varchar(255),@outResult varchar(100),@move varchar(255),@rear varchar(255);
|
||||
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
|
||||
IF @r<>10 THROW 51930,'Failed to prepare genuine Legacy outbound fixture',1;
|
||||
INSERT dbo.bas_stock(stock_id,cargo_space_id,tray_code,spare4,spare1,spare2,spare3,product_id,quantity,out_quantity,in_date)
|
||||
VALUES('GUEST-S','120703','NEW-TRAY','NEW','D','E','F','P-GUEST',1,0,GETDATE());
|
||||
UPDATE dbo.bas_cargo_space SET status='2',tray_code='NEW-TRAY' WHERE cargo_space_id='120703';
|
||||
DELETE dbo.mydo_job WHERE job_id='IN-NEW';
|
||||
UPDATE dbo.bas_stock SET product_id='P-REAR',in_date=GETDATE() WHERE stock_id='S1';
|
||||
INSERT dbo.out_stockout(stockout_id,warehouse_id,stockout_source,fid,spare6,spare8,out_type)
|
||||
VALUES('ORDER1','01','0','1','1','2','21');
|
||||
INSERT dbo.out_stockout_detail(stockout_id,stockout_detail_id,product_id,spare1,spare2,spare3,spare4,quantity,allot_quantity)
|
||||
VALUES('ORDER1','ORDER1-1','P-REAR','A','B','C','OLD',1,0);
|
||||
EXEC dbo.proc_out_auto_allocation 'ORDER1-1',@outResult OUTPUT,@m OUTPUT;
|
||||
IF @outResult<>'10' BEGIN DECLARE @failure nvarchar(2048)=N'Legacy auto allocation failed: '+ISNULL(@m,'NULL'); THROW 51931,@failure,1; END;
|
||||
SELECT @move=job_id FROM dbo.mydo_job WHERE in_or_out=30 AND tray_code='NEW-TRAY';
|
||||
SELECT @rear=job_id FROM dbo.mydo_job WHERE in_or_out=21 AND tray_code='RESIDENT';
|
||||
IF @move IS NULL OR @rear IS NULL THROW 51932,'Actual Legacy must create both correct tray tasks',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id=@move AND cargo_space_id='120703' AND status='1')
|
||||
THROW 51933,'Legacy must move the outer guest, not the resident',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id=@rear AND cargo_space_id='120803' AND status='8')
|
||||
THROW 51934,'Legacy rear task must be invisible to the status=1 dispatch scheduler',1;
|
||||
EXEC dbo.proc_yk_complete @move,@r OUTPUT;
|
||||
IF @r<>10 OR NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id=@rear AND status='1')
|
||||
THROW 51935,'Actual Legacy relocation completion must release the rear task',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.bas_stock WHERE stock_id='S1' AND spare4='OLD' AND out_quantity=1)
|
||||
THROW 51936,'Resident allocation and project must retain Legacy values',1;
|
||||
22
scripts/mixed-pilot/tests/seed.sql
Normal file
22
scripts/mixed-pilot/tests/seed.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
INSERT dbo.bas_cargo_space(cargo_space_id,warehouse_id,the_floor,the_platoon,the_column,group_coding_no,group_coding,this_cargo_area,status,locked,tray_code)
|
||||
VALUES ('120803','01',3,12,8,'C1203','1','C','2','N','RESIDENT'),
|
||||
('120703','01',3,12,7,'C1203','2','C','1','N',NULL),
|
||||
('020804','01',4,2,8,'C0204','1','C','2','N','RESIDENT4'),
|
||||
('020704','01',4,2,7,'C0204','2','C','1','N',NULL),
|
||||
('020805','01',5,2,8,'C0205','1','C','2','N','RESIDENT5'),
|
||||
('020705','01',5,2,7,'C0205','2','C','1','N',NULL),
|
||||
('130803','01',3,13,8,'C1303','1','E','1','N',NULL),
|
||||
('130703','01',3,13,7,'C1303','2','E','1','N',NULL),
|
||||
('140803','01',3,14,8,'C1403','1','E','1','N',NULL),
|
||||
('140703','01',3,14,7,'C1403','2','E','1','N',NULL),
|
||||
('080801','01',1,8,8,'C0801','1','C','2','N','OTHER-RESIDENT'),
|
||||
('080701','01',1,8,7,'C0801','2','C','1','N',NULL);
|
||||
INSERT dbo.bas_stock(stock_id,cargo_space_id,tray_code,spare4,spare1,spare2,spare3,quantity,out_quantity)
|
||||
VALUES ('S1','120803','RESIDENT','OLD','A','B','C',10,0),
|
||||
('S4','020804','RESIDENT4','OLD4','A','B','C',10,0),
|
||||
('S5','020805','RESIDENT5','OLD5','A','B','C',10,0),
|
||||
('SX','080801','OTHER-RESIDENT','OLD-X','A','B','C',10,0);
|
||||
INSERT dbo.mydo_job(job_id,tray_code,warehouse_id,line_id,in_or_out,status,execution_phase,fault_flag)
|
||||
VALUES ('IN-NEW','NEW-TRAY','01','NEW',1,'1','0','0');
|
||||
INSERT dbo.mydo_job_detail(job_detail_id,job_id,spare4,spare1,spare2,spare3,product_id,in_quantity)
|
||||
VALUES ('IN-NEW-1','IN-NEW','NEW','D','E','F','P1',1);
|
||||
112
scripts/mixed-pilot/wrappers.sql
Normal file
112
scripts/mixed-pilot/wrappers.sql
Normal file
@@ -0,0 +1,112 @@
|
||||
ALTER PROCEDURE dbo.proc_allot_space @v_tray_code varchar(80),@v_code varchar(80),@v_result int OUTPUT,@v_resultmsg varchar(255) OUTPUT AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
IF NOT EXISTS(SELECT 1 FROM mixed_pilot.control WHERE enabled=1) AND NOT EXISTS(SELECT 1 FROM mixed_pilot.lane WHERE protected=1)
|
||||
BEGIN
|
||||
EXEC mixed_pilot.original_allot_space @v_tray_code,@v_code,@v_result OUTPUT,@v_resultmsg OUTPUT;
|
||||
RETURN;
|
||||
END;
|
||||
SET XACT_ABORT OFF;
|
||||
DECLARE @own bit=CASE WHEN @@TRANCOUNT=0 THEN 1 ELSE 0 END,@lock int,@dummy int,@job varchar(255),@slot varchar(80);
|
||||
IF @own=1 BEGIN TRANSACTION; ELSE SAVE TRANSACTION mp_inbound;
|
||||
BEGIN TRY
|
||||
EXEC @lock=sys.sp_getapplock @Resource='FT546.mixed_pilot.inbound',@LockMode='Exclusive',@LockOwner='Transaction',@LockTimeout=500;
|
||||
IF @lock<0 THROW 51120,N'试点分配正在处理,请稍后重试。',1;
|
||||
SELECT @dummy=COUNT(*) FROM dbo.bas_cargo_space s WITH(UPDLOCK,HOLDLOCK)
|
||||
JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no WHERE l.enabled=1 OR l.protected=1;
|
||||
SELECT @dummy=COUNT(*) FROM dbo.mydo_job WITH(UPDLOCK,HOLDLOCK) WHERE tray_code=@v_tray_code;
|
||||
IF @dummy<>1 THROW 51121,N'托盘任务不存在或不唯一,请核对后重试。',1;
|
||||
SELECT @job=job_id,@slot=NULLIF(cargo_space_id,'') FROM dbo.mydo_job WHERE tray_code=@v_tray_code AND in_or_out<20;
|
||||
IF @job IS NULL THROW 51122,N'没有对应的入库任务。',1;
|
||||
IF @slot IS NOT NULL
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE cargo_space_id=@slot AND status='3')
|
||||
THROW 51123,N'已有入库分配的货位状态不一致,请核对任务。',1;
|
||||
SET @v_result=10; SET @v_resultmsg='成功';
|
||||
END
|
||||
ELSE EXEC mixed_pilot.allot_space_with_fallback @v_tray_code,@v_code,@v_result OUTPUT,@v_resultmsg OUTPUT;
|
||||
IF @own=1 COMMIT TRANSACTION;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF @own=1 AND XACT_STATE()<>0 ROLLBACK TRANSACTION;
|
||||
ELSE IF XACT_STATE()=1 ROLLBACK TRANSACTION mp_inbound;
|
||||
ELSE IF XACT_STATE()=-1 THROW;
|
||||
SET @v_result=2; SET @v_resultmsg=CONVERT(varchar(255),ERROR_MESSAGE());
|
||||
END CATCH;
|
||||
END;
|
||||
GO
|
||||
ALTER PROCEDURE dbo.proc_yk_job_add @v_cargo_space_id varchar(255),@v_tar_cargo_space_id varchar(255),@v_tray_code varchar(255) AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
IF NOT EXISTS(SELECT 1 FROM mixed_pilot.lane WHERE enabled=1 OR protected=1)
|
||||
BEGIN
|
||||
EXEC mixed_pilot.original_yk_job_add @v_cargo_space_id,@v_tar_cargo_space_id,@v_tray_code;
|
||||
RETURN;
|
||||
END;
|
||||
DECLARE @own bit=CASE WHEN @@TRANCOUNT=0 THEN 1 ELSE 0 END,@job varchar(255),@dummy int;
|
||||
IF @own=1 BEGIN TRANSACTION; ELSE SAVE TRANSACTION mp_move;
|
||||
BEGIN TRY
|
||||
SELECT @dummy=COUNT(*) FROM dbo.bas_cargo_space WITH(UPDLOCK,HOLDLOCK) WHERE cargo_space_id IN(@v_cargo_space_id,@v_tar_cargo_space_id);
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space s JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no
|
||||
WHERE s.cargo_space_id IN(@v_cargo_space_id,@v_tar_cargo_space_id) AND (l.protected=1 OR l.enabled=1))
|
||||
AND NOT EXISTS(SELECT 1 FROM mixed_pilot.move_audit WHERE target_slot=@v_tar_cargo_space_id)
|
||||
BEGIN
|
||||
EXEC mixed_pilot.original_yk_job_add @v_cargo_space_id,@v_tar_cargo_space_id,@v_tray_code;
|
||||
IF @own=1 COMMIT TRANSACTION;
|
||||
RETURN;
|
||||
END;
|
||||
IF EXISTS(SELECT 1 FROM mixed_pilot.move_audit a JOIN dbo.mydo_job j ON j.job_id=a.job_id
|
||||
WHERE a.source_slot=@v_cargo_space_id AND a.target_slot=@v_tar_cargo_space_id AND a.tray_code=@v_tray_code AND a.completed_at IS NULL
|
||||
AND j.in_or_out=30 AND j.cargo_space_id=a.source_slot AND j.target_cargo_space_id=a.target_slot AND j.tray_code=a.tray_code)
|
||||
BEGIN
|
||||
IF @own=1 COMMIT TRANSACTION;
|
||||
RETURN;
|
||||
END;
|
||||
EXEC mixed_pilot.check_move @v_cargo_space_id,@v_tar_cargo_space_id,@v_tray_code;
|
||||
EXEC mixed_pilot.original_yk_job_add @v_cargo_space_id,@v_tar_cargo_space_id,@v_tray_code;
|
||||
SELECT @job=job_id FROM dbo.mydo_job WHERE in_or_out=30 AND cargo_space_id=@v_cargo_space_id AND target_cargo_space_id=@v_tar_cargo_space_id AND tray_code=@v_tray_code;
|
||||
IF @job IS NULL THROW 51124,N'未能建立可核验的移库任务。',1;
|
||||
INSERT mixed_pilot.move_audit(job_id,source_slot,target_slot,tray_code) VALUES(@job,@v_cargo_space_id,@v_tar_cargo_space_id,@v_tray_code);
|
||||
IF @own=1 COMMIT TRANSACTION;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF @own=1 AND XACT_STATE()<>0 ROLLBACK TRANSACTION;
|
||||
ELSE IF XACT_STATE()=1 ROLLBACK TRANSACTION mp_move;
|
||||
THROW;
|
||||
END CATCH;
|
||||
END;
|
||||
GO
|
||||
ALTER PROCEDURE dbo.proc_yk_complete @v_job_id varchar(255),@v_result int OUTPUT AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
IF NOT EXISTS(SELECT 1 FROM mixed_pilot.move_audit WHERE job_id=@v_job_id)
|
||||
BEGIN
|
||||
EXEC mixed_pilot.original_yk_complete @v_job_id,@v_result OUTPUT;
|
||||
RETURN;
|
||||
END;
|
||||
DECLARE @own bit=CASE WHEN @@TRANCOUNT=0 THEN 1 ELSE 0 END,@src varchar(255),@dst varchar(255),@tray varchar(255),@done datetime2,@dummy int;
|
||||
IF @own=1 BEGIN TRANSACTION; ELSE SAVE TRANSACTION mp_complete;
|
||||
BEGIN TRY
|
||||
SELECT @src=source_slot,@dst=target_slot,@tray=tray_code,@done=completed_at FROM mixed_pilot.move_audit WITH(UPDLOCK,HOLDLOCK) WHERE job_id=@v_job_id;
|
||||
IF @done IS NULL
|
||||
BEGIN
|
||||
SELECT @dummy=COUNT(*) FROM dbo.bas_cargo_space WITH(UPDLOCK,HOLDLOCK) WHERE cargo_space_id IN(@src,@dst);
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WITH(UPDLOCK,HOLDLOCK) WHERE job_id=@v_job_id AND in_or_out=30 AND tray_code=@tray AND cargo_space_id=@src AND target_cargo_space_id=@dst)
|
||||
OR NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE cargo_space_id=@src AND status='4' AND tray_code=@tray)
|
||||
OR NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE cargo_space_id=@dst AND status='3' AND NULLIF(tray_code,'') IS NULL)
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_stock WHERE cargo_space_id=@dst)
|
||||
OR NOT EXISTS(SELECT 1 FROM dbo.bas_stock WHERE cargo_space_id=@src AND tray_code=@tray)
|
||||
OR EXISTS(SELECT 1 FROM dbo.bas_stock WHERE cargo_space_id=@src AND (tray_code<>@tray OR tray_code IS NULL))
|
||||
THROW 51125,N'移库完成的起终点或库存不一致,未修改库存。',1;
|
||||
EXEC mixed_pilot.original_yk_complete @v_job_id,@v_result OUTPUT;
|
||||
UPDATE mixed_pilot.move_audit SET completed_at=SYSUTCDATETIME() WHERE job_id=@v_job_id;
|
||||
END;
|
||||
SET @v_result=10;
|
||||
IF @own=1 COMMIT TRANSACTION;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF @own=1 AND XACT_STATE()<>0 ROLLBACK TRANSACTION;
|
||||
ELSE IF XACT_STATE()=1 ROLLBACK TRANSACTION mp_complete;
|
||||
THROW;
|
||||
END CATCH;
|
||||
END;
|
||||
57
scripts/shadow-lane-lock/README.md
Normal file
57
scripts/shadow-lane-lock/README.md
Normal 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 失败回滚及恢复原文。退出时删除本次测试库;此测试不验证现场托盘的实际规划效果。
|
||||
22
scripts/shadow-lane-lock/assertions.sql
Normal file
22
scripts/shadow-lane-lock/assertions.sql
Normal 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;
|
||||
43
scripts/shadow-lane-lock/fixture.sql
Normal file
43
scripts/shadow-lane-lock/fixture.sql
Normal 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);
|
||||
3
scripts/shadow-lane-lock/repair-shadow-lane-lock.cmd
Normal file
3
scripts/shadow-lane-lock/repair-shadow-lane-lock.cmd
Normal file
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0repair-shadow-lane-lock.ps1" %*
|
||||
exit /b %errorlevel%
|
||||
136
scripts/shadow-lane-lock/repair-shadow-lane-lock.ps1
Normal file
136
scripts/shadow-lane-lock/repair-shadow-lane-lock.ps1
Normal 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()
|
||||
}
|
||||
104
scripts/shadow-lane-lock/test-shadow-lane-lock.ps1
Normal file
104
scripts/shadow-lane-lock/test-shadow-lane-lock.ps1
Normal 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()
|
||||
}
|
||||
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