From 1225cf7287bd90ddd82ff4bf209e4e7b3d427533 Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Wed, 16 Sep 2026 14:12:12 +0800 Subject: [PATCH] feat: prepare warehouse 01 floor 1 inbound pilot --- ...026-09-16-ft546wms-layer1-inbound-pilot.md | 38 +++ ...16-ft546wms-layer1-inbound-pilot-design.md | 56 ++++ .../pilot/Layer1InboundPilotProperties.java | 54 +++ .../pilot/Layer1InboundPilotResult.java | 30 ++ .../pilot/Layer1InboundPilotService.java | 303 +++++++++++++++++ .../shadow/ShadowPlanningEngine.java | 15 +- .../shadow/mapper/ShadowInputMapper.java | 6 + .../api/service/impl/ToWmsServiceImpl.java | 20 ++ .../job/mydojob/mapper/MydoJobMapper.java | 2 + .../src/main/resources/application.yml | 6 + .../mapper/algorithm/ShadowInputMapper.xml | 7 + .../resources/mapper/job/MydoJobMapper.xml | 16 + .../Layer1InboundPilotOperationsTest.java | 87 +++++ .../pilot/Layer1InboundPilotServiceTest.java | 243 ++++++++++++++ .../shadow/ShadowConfigurationTest.java | 23 ++ .../shadow/ShadowPlanningEngineTest.java | 25 ++ .../impl/ToWmsServiceImplShadowTest.java | 85 +++++ scripts/layer1-inbound-pilot/README.md | 31 ++ .../check-layer1-inbound-pilot.cmd | 11 + .../check-layer1-inbound-pilot.sql | 31 ++ ...WMS-LAYER1-PILOT-DISABLED.run.xml.disabled | 17 + .../install-layer1-inbound-pilot.cmd | 14 + .../install-layer1-inbound-pilot.sql | 316 ++++++++++++++++++ .../rollback-layer1-inbound-pilot.cmd | 16 + .../rollback-layer1-inbound-pilot.sql | 16 + 25 files changed, 1465 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-16-ft546wms-layer1-inbound-pilot.md create mode 100644 docs/superpowers/specs/2026-09-16-ft546wms-layer1-inbound-pilot-design.md create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/algorithm/pilot/Layer1InboundPilotProperties.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/algorithm/pilot/Layer1InboundPilotResult.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/algorithm/pilot/Layer1InboundPilotService.java create mode 100644 ruoyi-admin/src/test/java/com/ruoyi/algorithm/pilot/Layer1InboundPilotOperationsTest.java create mode 100644 ruoyi-admin/src/test/java/com/ruoyi/algorithm/pilot/Layer1InboundPilotServiceTest.java create mode 100644 scripts/layer1-inbound-pilot/README.md create mode 100644 scripts/layer1-inbound-pilot/check-layer1-inbound-pilot.cmd create mode 100644 scripts/layer1-inbound-pilot/check-layer1-inbound-pilot.sql create mode 100644 scripts/layer1-inbound-pilot/idea-template/FT546WMS-LAYER1-PILOT-DISABLED.run.xml.disabled create mode 100644 scripts/layer1-inbound-pilot/install-layer1-inbound-pilot.cmd create mode 100644 scripts/layer1-inbound-pilot/install-layer1-inbound-pilot.sql create mode 100644 scripts/layer1-inbound-pilot/rollback-layer1-inbound-pilot.cmd create mode 100644 scripts/layer1-inbound-pilot/rollback-layer1-inbound-pilot.sql diff --git a/docs/superpowers/plans/2026-09-16-ft546wms-layer1-inbound-pilot.md b/docs/superpowers/plans/2026-09-16-ft546wms-layer1-inbound-pilot.md new file mode 100644 index 0000000..87bbe42 --- /dev/null +++ b/docs/superpowers/plans/2026-09-16-ft546wms-layer1-inbound-pilot.md @@ -0,0 +1,38 @@ +# FT546WMS Floor 1 Inbound Pilot Implementation Plan + +**Goal:** Prepare, but do not activate, a warehouse `01` floor `1` inbound pilot with atomic mutation and automatic Legacy fallback. + +**Constraints:** No field restart, process switch, SQL installation, or production business-data write. SQL Server 2016 syntax only. The independent pilot switch remains disabled in every prepared startup file. + +## Task 1: Routing contract + +- [x] Add failing configuration tests for the disabled default and explicit pilot properties. +- [x] Add failing service tests for out-of-scope Legacy routing, successful pilot single-writer routing, conflict fallback, and exception fallback. +- [x] Add the pilot properties, result contract, and the minimal `ToWmsServiceImpl` routing seam. + +## Task 2: Floor-constrained bounded planning + +- [x] Add a failing planning test proving `targetFloor=1` is forwarded to the algorithm. +- [x] Add a synchronous pilot planner that accepts only a current published warehouse `01` snapshot. +- [x] Run planning in a dedicated single-thread daemon executor with a bounded wait; timeout/cancellation remains read-only. +- [x] Reject missing, stale, changing, non-ready, or non-assigned plans and route them to Legacy. + +## Task 3: Atomic production mutation + +- [x] Add mapper contract tests and static SQL safety tests first. +- [x] Add `dbo.proc_allot_layer1_inbound_pilot` with one transaction, `XACT_ABORT`, update/range locks, live floor/lane/space checks, guarded row counts, and LED parity. +- [x] Add Java mutation result handling and post-exception assignment resolution so an uncertain commit never triggers a second writer. +- [x] Record best-effort `INBOUND_PILOT` evidence in existing `alg.decision_log`. + +## Task 4: Operations package + +- [x] Add SQL Server 2016 install, read-only check, rollback, and CMD wrappers. +- [x] Add a disabled IDEA run configuration template and a concise field runbook. +- [x] Ensure every prepared configuration sets `FORTUCKY_INBOUND_PILOT_ENABLED=false`. + +## Task 5: Verification and delivery + +- [x] Run focused red/green tests, all affected module tests, and offline packaging. +- [x] Inspect the JAR for the new classes/configuration and verify no secret is packaged by this change. +- [x] Request an independent code review and address critical or important findings. +- [ ] Prepare and upload versioned artifacts only; do not install, restart, switch, or activate. diff --git a/docs/superpowers/specs/2026-09-16-ft546wms-layer1-inbound-pilot-design.md b/docs/superpowers/specs/2026-09-16-ft546wms-layer1-inbound-pilot-design.md new file mode 100644 index 0000000..ebf14ee --- /dev/null +++ b/docs/superpowers/specs/2026-09-16-ft546wms-layer1-inbound-pilot-design.md @@ -0,0 +1,56 @@ +# FT546WMS Warehouse 01 Floor 1 Inbound Pilot Design + +## Goal + +Prepare a narrowly scoped inbound pilot for warehouse `01`, floor `1`, without enabling it or changing the running field system. The candidate remains Legacy-only by default. A later operator-controlled restart may enable the pilot after the database procedure has been installed and checked. + +## Safety invariants + +1. `FORTUCKY_INBOUND_PILOT_ENABLED` defaults to `false` and is independent of global algorithm mode. +2. Only a request with `RequestEnter.floor == "1"` and a job whose warehouse is equivalent to `01` is eligible. +3. Non-eligible requests execute the existing Legacy path unchanged. +4. Planning is read-only, uses one published shadow snapshot, is restricted to floor `1`, and has a bounded caller wait. +5. The algorithm writer and `dbo.proc_allot_space` are mutually exclusive. A committed pilot result never calls Legacy. +6. Missing, stale, changing, rejected, timed-out, or failed planning falls back to Legacy. +7. The production mutation is one SQL Server transaction. It revalidates the job, tray, warehouse, floor, snapshot generation and age, selected space, lane continuity, outbound obstruction, duplicate assignment, and live occupancy before updating anything. +8. A mutation conflict or a proven rollback falls back to Legacy. An ambiguous client failure is resolved by rereading the job; an existing assignment is returned rather than risking a second writer. +9. The mutation updates only the same business state owned by the Legacy inbound allocation path: the selected `mydo_job`, selected `bas_cargo_space`, and entry LED row for `L1004` or `L1005`. +10. Every eligible attempt writes best-effort diagnostic evidence to existing `alg.decision_log`; diagnostic failure cannot change the business result. +11. Outbound, relocation, other warehouses, and other floors remain unchanged. + +## Request flow + +1. Load the inbound job using the existing task number lookup. +2. If it is already allocated, return it unchanged. +3. Ask the floor-1 pilot router for an allocation. +4. When the pilot is disabled or the request is outside scope, immediately continue with the existing shadow-observation plus Legacy allocation path. +5. For an eligible request, load and validate the current published snapshot, then plan asynchronously with `targetFloor=1` and a bounded wait. +6. If planning returns one conservative eligible proposal, call the dedicated atomic mutation procedure once. +7. On procedure success or a reread that proves an assignment already exists, return the reloaded job and do not call Legacy. +8. For every other outcome, invoke the existing Legacy procedure exactly once and preserve its response. + +## Atomic mutation contract + +The prepared procedure is `dbo.proc_allot_layer1_inbound_pilot`. It accepts task, tray, device, selected space, warehouse, floor, snapshot generation, and maximum snapshot age. It returns a numeric result, message, and final space. + +Within `SET XACT_ABORT ON` and one transaction it uses update/range locks to prove: + +- exactly one unfinished inbound job matches task and tray; +- the job does not already point at another space; +- the requested published snapshot is still current and fresh; +- the selected live cargo space belongs to warehouse `01`, floor `1`, area A/C, is empty, unlocked, and has no stock; +- no active job already owns or targets that space; +- the lane has no locked or moving (`3`/`4`) space and no active outbound job; +- no physically inner free space exists, so the proposal remains the next legal slot. + +The guarded updates must each affect exactly one row. Any failed guard rolls back and returns a non-success code. SQL exceptions roll back and escape to the Java resolver. + +## Evidence + +`alg.decision_log` uses decision type `INBOUND_PILOT`. The reason records task, tray, proposal, final space, elapsed time, outcome, and fallback reason. The snapshot generation is stored in `input_version`. Evidence is diagnostic only and never participates in the business transaction. + +## Deployment boundary + +This change prepares source, tests, SQL install/check/rollback files, a JAR, and a disabled IDEA configuration. Preparation does not execute the SQL installer, upload over the running JAR, restart WMS, or set the pilot switch to true. + +Later activation requires this order: stop the candidate WMS during an agreed window, install and check the procedure, start the prepared JAR with shadow enabled and pilot disabled, verify Legacy operation, then perform a separate restart with the floor-1 pilot enabled. Rollback is a restart with the pilot disabled; the procedure may remain installed or be removed only after the pilot is disabled. diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/pilot/Layer1InboundPilotProperties.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/pilot/Layer1InboundPilotProperties.java new file mode 100644 index 0000000..c75c9c3 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/pilot/Layer1InboundPilotProperties.java @@ -0,0 +1,54 @@ +package com.ruoyi.algorithm.pilot; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "fortucky.inbound-pilot") +public class Layer1InboundPilotProperties { + private boolean enabled; + private String warehouseId = "01"; + private int floor = 1; + private long planningTimeoutMillis = 1500L; + private int snapshotMaxAgeSeconds = 60; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getWarehouseId() { + return warehouseId; + } + + public void setWarehouseId(String warehouseId) { + this.warehouseId = warehouseId == null ? "" : warehouseId.trim(); + } + + public int getFloor() { + return floor; + } + + public void setFloor(int floor) { + this.floor = floor; + } + + public long getPlanningTimeoutMillis() { + return planningTimeoutMillis; + } + + public void setPlanningTimeoutMillis(long planningTimeoutMillis) { + this.planningTimeoutMillis = planningTimeoutMillis; + } + + public int getSnapshotMaxAgeSeconds() { + return snapshotMaxAgeSeconds; + } + + public void setSnapshotMaxAgeSeconds(int snapshotMaxAgeSeconds) { + this.snapshotMaxAgeSeconds = snapshotMaxAgeSeconds; + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/pilot/Layer1InboundPilotResult.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/pilot/Layer1InboundPilotResult.java new file mode 100644 index 0000000..f349b64 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/pilot/Layer1InboundPilotResult.java @@ -0,0 +1,30 @@ +package com.ruoyi.algorithm.pilot; + +public record Layer1InboundPilotResult(Status status, String finalSpaceId, String fallbackReason) { + public enum Status { + NOT_ELIGIBLE, + COMMITTED, + FALLBACK, + INDETERMINATE + } + + public static Layer1InboundPilotResult notEligible() { + return new Layer1InboundPilotResult(Status.NOT_ELIGIBLE, null, null); + } + + public static Layer1InboundPilotResult committed(String finalSpaceId) { + return new Layer1InboundPilotResult(Status.COMMITTED, finalSpaceId, null); + } + + public static Layer1InboundPilotResult fallback(String reason) { + return new Layer1InboundPilotResult(Status.FALLBACK, null, reason); + } + + public static Layer1InboundPilotResult indeterminate(String reason) { + return new Layer1InboundPilotResult(Status.INDETERMINATE, null, reason); + } + + public boolean committed() { + return status == Status.COMMITTED; + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/pilot/Layer1InboundPilotService.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/pilot/Layer1InboundPilotService.java new file mode 100644 index 0000000..9ccd6d0 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/pilot/Layer1InboundPilotService.java @@ -0,0 +1,303 @@ +package com.ruoyi.algorithm.pilot; + +import com.ruoyi.algorithm.shadow.InboundShadowObservation; +import com.ruoyi.algorithm.shadow.PublishedShadowSnapshot; +import com.ruoyi.algorithm.shadow.ShadowPlanningEngine; +import com.ruoyi.algorithm.shadow.ShadowSnapshotDescriptor; +import com.ruoyi.algorithm.shadow.mapper.ShadowInputMapper; +import com.ruoyi.api.domain.RequestEnter; +import com.ruoyi.job.mydojob.domain.MydoJob; +import com.ruoyi.job.mydojob.mapper.MydoJobMapper; +import jakarta.annotation.PreDestroy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +@Component +public class Layer1InboundPilotService implements AutoCloseable { + private static final Logger LOG = LoggerFactory.getLogger(Layer1InboundPilotService.class); + private static final String PUBLISHED = "PUBLISHED"; + private static final String PILOT_WAREHOUSE_ID = "01"; + private static final int PILOT_FLOOR = 1; + private static final int REASON_LIMIT = 3900; + + private final Layer1InboundPilotProperties properties; + private final ShadowInputMapper shadowInputMapper; + private final ShadowPlanningEngine planningEngine; + private final MydoJobMapper jobMapper; + private final Clock clock; + private final ExecutorService planningExecutor; + + @Autowired + public Layer1InboundPilotService(Layer1InboundPilotProperties properties, + ShadowInputMapper shadowInputMapper, ShadowPlanningEngine planningEngine, + MydoJobMapper jobMapper) { + this(properties, shadowInputMapper, planningEngine, jobMapper, Clock.systemUTC(), newPlanningExecutor()); + } + + Layer1InboundPilotService(Layer1InboundPilotProperties properties, + ShadowInputMapper shadowInputMapper, ShadowPlanningEngine planningEngine, + MydoJobMapper jobMapper, Clock clock, ExecutorService planningExecutor) { + this.properties = Objects.requireNonNull(properties, "properties"); + this.shadowInputMapper = Objects.requireNonNull(shadowInputMapper, "shadowInputMapper"); + this.planningEngine = Objects.requireNonNull(planningEngine, "planningEngine"); + this.jobMapper = Objects.requireNonNull(jobMapper, "jobMapper"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.planningExecutor = Objects.requireNonNull(planningExecutor, "planningExecutor"); + } + + public Layer1InboundPilotResult attempt(RequestEnter request, MydoJob job) { + if (!eligible(request, job)) { + return Layer1InboundPilotResult.notEligible(); + } + + long startedNanos = System.nanoTime(); + long generation = 0L; + String proposedSpace = null; + Layer1InboundPilotResult result; + try { + Proposal proposal = planWithTimeout(request, job); + generation = proposal.generation(); + proposedSpace = proposal.proposedSpaceId(); + if (proposal.fallbackReason() != null) { + result = Layer1InboundPilotResult.fallback(proposal.fallbackReason()); + } else { + result = mutate(request, job, proposal); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + result = Layer1InboundPilotResult.fallback("PLAN_INTERRUPTED"); + } catch (TimeoutException timeout) { + result = Layer1InboundPilotResult.fallback("PLAN_TIMEOUT"); + } catch (RejectedExecutionException busy) { + result = Layer1InboundPilotResult.fallback("PLAN_BUSY"); + } catch (ExecutionException planningFailure) { + result = Layer1InboundPilotResult.fallback("PLAN_ERROR_" + simpleName(planningFailure.getCause())); + } catch (RuntimeException | Error unexpected) { + result = Layer1InboundPilotResult.fallback("PILOT_ERROR_" + simpleName(unexpected)); + } + + audit(request, job, generation, proposedSpace, result, elapsedMillis(startedNanos)); + return result; + } + + private Proposal planWithTimeout(RequestEnter request, MydoJob job) + throws InterruptedException, ExecutionException, TimeoutException { + long timeout = properties.getPlanningTimeoutMillis(); + if (timeout < 1L || properties.getSnapshotMaxAgeSeconds() < 5 + || properties.getSnapshotMaxAgeSeconds() > 300) { + return Proposal.fallback("INVALID_PILOT_CONFIGURATION"); + } + Future future = planningExecutor.submit(() -> plan(request, job)); + try { + return future.get(timeout, TimeUnit.MILLISECONDS); + } catch (TimeoutException timeoutException) { + future.cancel(true); + throw timeoutException; + } + } + + private Proposal plan(RequestEnter request, MydoJob job) { + String warehouseId = PILOT_WAREHOUSE_ID; + ShadowSnapshotDescriptor descriptor = shadowInputMapper.selectPublishedSnapshot(warehouseId); + String invalid = invalidSnapshot(descriptor, warehouseId); + if (invalid != null) { + return Proposal.fallback(invalid); + } + PublishedShadowSnapshot snapshot = new PublishedShadowSnapshot( + descriptor.getSnapshotGeneration(), descriptor.getWarehouseId(), + descriptor.getSynchronizedAt(), descriptor.getSourceSignature()); + InboundShadowObservation observation = planningEngine.planInbound(snapshot, + request.getTaskNo(), job.getTrayCode(), PILOT_FLOOR); + if (!observation.observed() || isBlank(observation.proposedSpaceId()) + || !("READY".equals(observation.resultCode()) + || "READY_CONSERVATIVE".equals(observation.resultCode()))) { + return Proposal.fallback(observation.resultCode() == null + ? "PLAN_NOT_READY" : "PLAN_" + observation.resultCode()); + } + return new Proposal(observation.snapshotGeneration(), observation.proposedSpaceId(), + observation.reason(), null); + } + + private String invalidSnapshot(ShadowSnapshotDescriptor descriptor, String warehouseId) { + if (descriptor == null || !PUBLISHED.equals(descriptor.getSnapshotStatus()) + || descriptor.getSnapshotGeneration() <= 0L + || descriptor.getSynchronizedAt() == null || isBlank(descriptor.getSourceSignature())) { + return "SNAPSHOT_NOT_READY"; + } + if (!warehouseEquivalent(descriptor.getWarehouseId(), warehouseId)) { + return "SNAPSHOT_WAREHOUSE_MISMATCH"; + } + Duration age = Duration.between(descriptor.getSynchronizedAt(), clock.instant()); + if (age.isNegative()) { + return "SNAPSHOT_TIME_INVALID"; + } + if (age.compareTo(Duration.ofSeconds(properties.getSnapshotMaxAgeSeconds())) > 0) { + return "SNAPSHOT_STALE"; + } + return null; + } + + private Layer1InboundPilotResult mutate(RequestEnter request, MydoJob job, Proposal proposal) { + HashMap arguments = new HashMap<>(); + arguments.put("v_task_no", request.getTaskNo()); + arguments.put("v_tray_code", job.getTrayCode()); + arguments.put("v_code", request.getDeviceCode()); + arguments.put("v_space_id", proposal.proposedSpaceId()); + arguments.put("v_warehouse_id", PILOT_WAREHOUSE_ID); + arguments.put("v_floor", PILOT_FLOOR); + arguments.put("v_snapshot_generation", proposal.generation()); + arguments.put("v_snapshot_max_age_seconds", properties.getSnapshotMaxAgeSeconds()); + try { + jobMapper.allocateLayer1InboundPilot(arguments); + int code = intValue(arguments.get("v_result"), -1); + String finalSpace = stringValue(arguments.get("v_final_space_id")); + if ((code == 10 || code == 11 || code == 12) && !isBlank(finalSpace)) { + return Layer1InboundPilotResult.committed(finalSpace); + } + return Layer1InboundPilotResult.fallback("MUTATION_" + code + "_" + + abbreviate(stringValue(arguments.get("v_resultmsg")), 160)); + } catch (RuntimeException | Error mutationFailure) { + return resolveMutationFailure(request.getTaskNo(), mutationFailure); + } + } + + private Layer1InboundPilotResult resolveMutationFailure(String taskNo, Throwable mutationFailure) { + try { + MydoJob current = jobMapper.selectInJobByTaskNo(taskNo); + if (current != null && !isBlank(current.getCargoSpaceId())) { + return Layer1InboundPilotResult.committed(current.getCargoSpaceId()); + } + return Layer1InboundPilotResult.fallback("MUTATION_ROLLED_BACK_" + simpleName(mutationFailure)); + } catch (RuntimeException | Error stateReadFailure) { + LOG.error("layer1_inbound_pilot_indeterminate task={} mutationError={} stateReadError={}", + taskNo, simpleName(mutationFailure), simpleName(stateReadFailure)); + return Layer1InboundPilotResult.indeterminate("MUTATION_STATE_UNKNOWN"); + } + } + + private boolean eligible(RequestEnter request, MydoJob job) { + return properties.isEnabled() && request != null && job != null + && properties.getFloor() == PILOT_FLOOR + && warehouseEquivalent(properties.getWarehouseId(), PILOT_WAREHOUSE_ID) + && Integer.toString(PILOT_FLOOR).equals(trim(request.getFloor())) + && warehouseEquivalent(job.getWarehouseId(), PILOT_WAREHOUSE_ID) + && !isBlank(request.getTaskNo()) && !isBlank(job.getTrayCode()) + && job.getCargoSpaceId() == null; + } + + private void audit(RequestEnter request, MydoJob job, long generation, String proposedSpace, + Layer1InboundPilotResult result, long elapsedMillis) { + String reason = "task=" + safe(request.getTaskNo()) + + "; tray=" + safe(job.getTrayCode()) + + "; proposedSpace=" + safe(proposedSpace) + + "; finalSpace=" + safe(result.finalSpaceId()) + + "; elapsedMs=" + elapsedMillis + + "; outcome=" + result.status() + + "; fallbackReason=" + safe(result.fallbackReason()); + try { + shadowInputMapper.insertPilotAudit(UUID.randomUUID().toString(), PILOT_WAREHOUSE_ID, + generation, result.status().name(), abbreviate(reason, REASON_LIMIT)); + } catch (RuntimeException | Error auditFailure) { + LOG.warn("layer1_inbound_pilot_audit_failed task={} error={}", + request.getTaskNo(), simpleName(auditFailure)); + } + } + + @PreDestroy + @Override + public void close() { + planningExecutor.shutdownNow(); + } + + private static ExecutorService newPlanningExecutor() { + return new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(1), runnable -> { + Thread thread = new Thread(runnable, "layer1-inbound-pilot-planner"); + thread.setDaemon(true); + return thread; + }, new ThreadPoolExecutor.AbortPolicy()); + } + + private static boolean warehouseEquivalent(String left, String right) { + String normalizedLeft = normalizeWarehouse(left); + String normalizedRight = normalizeWarehouse(right); + return normalizedLeft != null && normalizedLeft.equals(normalizedRight); + } + + private static String normalizeWarehouse(String value) { + String trimmed = trim(value); + if (trimmed == null || trimmed.isEmpty()) { + return null; + } + if (trimmed.chars().allMatch(Character::isDigit)) { + String normalized = trimmed.replaceFirst("^0+(?!$)", ""); + return normalized.isEmpty() ? "0" : normalized; + } + return trimmed; + } + + private static int intValue(Object value, int fallback) { + if (value instanceof Number number) { + return number.intValue(); + } + try { + return Integer.parseInt(Objects.toString(value, "")); + } catch (NumberFormatException ignored) { + return fallback; + } + } + + private static String stringValue(Object value) { + return value == null ? null : value.toString(); + } + + private static String simpleName(Throwable failure) { + return failure == null ? "UNKNOWN" : failure.getClass().getSimpleName(); + } + + private static long elapsedMillis(long startedNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); + } + + private static String safe(String value) { + return value == null ? "" : abbreviate(value.replace(';', '_'), 255); + } + + private static String abbreviate(String value, int limit) { + if (value == null || value.length() <= limit) { + return value; + } + return value.substring(0, limit); + } + + private static String trim(String value) { + return value == null ? null : value.trim(); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private record Proposal(long generation, String proposedSpaceId, String reason, String fallbackReason) { + private static Proposal fallback(String reason) { + return new Proposal(0L, null, null, reason); + } + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java index 2bde4d2..c5ff374 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java @@ -82,7 +82,16 @@ public class ShadowPlanningEngine { public InboundShadowObservation planInbound(PublishedShadowSnapshot snapshot, String taskNo, String trayNo) { return planWithStableSnapshot(snapshot, - () -> planInboundInsideTransaction(snapshot, taskNo, trayNo)); + () -> planInboundInsideTransaction(snapshot, taskNo, trayNo, null)); + } + + public InboundShadowObservation planInbound(PublishedShadowSnapshot snapshot, + String taskNo, String trayNo, Integer targetFloor) { + if (targetFloor == null || targetFloor <= 0) { + throw new IllegalArgumentException("targetFloor must be positive"); + } + return planWithStableSnapshot(snapshot, + () -> planInboundInsideTransaction(snapshot, taskNo, trayNo, targetFloor)); } public OutboundShadowObservation planProjectOutbound(PublishedShadowSnapshot snapshot, @@ -245,7 +254,7 @@ public class ShadowPlanningEngine { } private InboundShadowObservation planInboundInsideTransaction(PublishedShadowSnapshot snapshot, - String taskNo, String trayNo) { + String taskNo, String trayNo, Integer targetFloor) { String warehouseId = snapshot.warehouseId(); ShadowInboundReference reference = inputMapper.selectInboundInput( warehouseId, snapshot.generation(), taskNo, trayNo); @@ -294,7 +303,7 @@ public class ShadowPlanningEngine { InboundRequest request = new InboundRequest(reference.getRequestId(), reference.getTrayNo(), reference.getProjectNo(), multipleProducts ? null : reference.getProductId(), multipleProducts ? null : reference.getLotNo(), - null, null, null, Set.of(), null, Set.copyOf(eligibleSpaces)); + null, null, targetFloor, Set.of(), null, Set.copyOf(eligibleSpaces)); InboundPlanningResult result = schedulingFacade.planInboundFromPublishedSnapshot(warehouseId, request); InboundPlan plan = result.plan(); if (plan.assignment().isPresent()) { diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java index 91b6975..ddc8179 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java @@ -50,6 +50,12 @@ public interface ShadowInputMapper { @Param("resultCode") String resultCode, @Param("reason") String reason); + int insertPilotAudit(@Param("decisionId") String decisionId, + @Param("warehouseId") String warehouseId, + @Param("inputVersion") long inputVersion, + @Param("resultCode") String resultCode, + @Param("reason") String reason); + void upsertRuntimeMetric(@Param("warehouseId") String warehouseId, @Param("metricName") String metricName, @Param("bucketUpperMicros") long bucketUpperMicros, diff --git a/ruoyi-admin/src/main/java/com/ruoyi/api/service/impl/ToWmsServiceImpl.java b/ruoyi-admin/src/main/java/com/ruoyi/api/service/impl/ToWmsServiceImpl.java index dc94938..6a1f50d 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/api/service/impl/ToWmsServiceImpl.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/api/service/impl/ToWmsServiceImpl.java @@ -8,6 +8,8 @@ import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; import com.ruoyi.algorithm.shadow.InboundShadowCompletion; import com.ruoyi.algorithm.shadow.ShadowObservationGateway; +import com.ruoyi.algorithm.pilot.Layer1InboundPilotResult; +import com.ruoyi.algorithm.pilot.Layer1InboundPilotService; import com.ruoyi.api.domain.*; import com.ruoyi.api.service.IToErpService; import com.ruoyi.api.service.IToWmsService; @@ -94,6 +96,8 @@ public class ToWmsServiceImpl implements IToWmsService { private MydoJobMapper mydoJobMapper; @Autowired private ShadowObservationGateway shadowObservationGateway; + @Autowired + private Layer1InboundPilotService layer1InboundPilotService; @Override @@ -411,6 +415,22 @@ public class ToWmsServiceImpl implements IToWmsService { if(inJob.getCargoSpaceId() != null && !"".equals(inJob.getCargoSpaceId())){ return AjaxResult.success(inJob); }else{ + Layer1InboundPilotResult pilotResult; + try { + pilotResult = layer1InboundPilotService.attempt(requestEnter, inJob); + } catch (RuntimeException | Error pilotFailure) { + pilotResult = Layer1InboundPilotResult.fallback("PILOT_BOUNDARY_FAILURE"); + } + if (pilotResult.committed()) { + MydoJob pilotJob = mydoJobMapper.selectInJobByTaskNo(taskNo); + if (pilotJob == null) { + return AjaxResult.error("一层入库试点已提交但任务重读失败,请重新扫码确认任务货位"); + } + return AjaxResult.success(pilotJob); + } + if (pilotResult.status() == Layer1InboundPilotResult.Status.INDETERMINATE) { + return AjaxResult.error("一层入库试点写入状态待确认,请重新扫码确认任务货位"); + } //分配货位 HashMap map = new HashMap<>(); diff --git a/ruoyi-admin/src/main/java/com/ruoyi/job/mydojob/mapper/MydoJobMapper.java b/ruoyi-admin/src/main/java/com/ruoyi/job/mydojob/mapper/MydoJobMapper.java index 635a162..04b0a97 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/job/mydojob/mapper/MydoJobMapper.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/job/mydojob/mapper/MydoJobMapper.java @@ -88,6 +88,8 @@ public interface MydoJobMapper void allotInJobSpace(HashMap map); + void allocateLayer1InboundPilot(HashMap map); + void ykCompleted(HashMap map); void updateLed(HashMap map); diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 3201cd6..e33b404 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -197,3 +197,9 @@ fortucky: warehouse-id: ${FORTUCKY_ALGORITHM_WAREHOUSE_ID:01} database-writes-enabled: ${FORTUCKY_ALGORITHM_DATABASE_WRITES_ENABLED:true} legacy-fallback-on-technical-error: ${FORTUCKY_ALGORITHM_LEGACY_FALLBACK_ON_TECHNICAL_ERROR:false} + inbound-pilot: + enabled: ${FORTUCKY_INBOUND_PILOT_ENABLED:false} + warehouse-id: ${FORTUCKY_INBOUND_PILOT_WAREHOUSE_ID:01} + floor: ${FORTUCKY_INBOUND_PILOT_FLOOR:1} + planning-timeout-millis: ${FORTUCKY_INBOUND_PILOT_PLANNING_TIMEOUT_MILLIS:1500} + snapshot-max-age-seconds: ${FORTUCKY_INBOUND_PILOT_SNAPSHOT_MAX_AGE_SECONDS:60} diff --git a/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml b/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml index 14a0b46..c6e1311 100644 --- a/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml +++ b/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml @@ -232,6 +232,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and convert(varchar(64), state.source_signature, 2) = #{sourceSignature} + + insert into alg.decision_log ( + decision_id, warehouse_id, decision_type, input_version, result_code, reason) + values (try_convert(uniqueidentifier, #{decisionId}), #{warehouseId}, 'INBOUND_PILOT', + convert(binary(8), #{inputVersion}), #{resultCode}, #{reason}) + + merge alg.shadow_runtime_metric with (holdlock) as target using (select #{warehouseId} as warehouse_id, diff --git a/ruoyi-admin/src/main/resources/mapper/job/MydoJobMapper.xml b/ruoyi-admin/src/main/resources/mapper/job/MydoJobMapper.xml index c112314..c64151a 100644 --- a/ruoyi-admin/src/main/resources/mapper/job/MydoJobMapper.xml +++ b/ruoyi-admin/src/main/resources/mapper/job/MydoJobMapper.xml @@ -308,6 +308,22 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" )} + +