Compare commits

11 Commits

72 changed files with 6663 additions and 124 deletions

View File

@@ -0,0 +1,443 @@
# Dense Outbound Relocation And Dispatch 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:** Add durable WCS outbound retry compensation, dense-lane outermost enforcement, automatic/manual same-floor relocation, and dense-lane outbound workflows without changing existing inbound or non-dense outbound behavior.
**Architecture:** Keep `mydo_job` as the business record and add a one-to-one `wcs_job_dispatch` outbox record. Route every outbound push through a single dispatch service that atomically claims attempts and calls a dense-lane eligibility service before selecting a type-specific WCS endpoint. Build relocation and lane-outbound workflows on shared dense-lane queries so automatic allocation, scheduled retry, manual push, location outbound, KPI relocation, and lane outbound enforce the same rules.
**Tech Stack:** Java 17, Spring Boot, MyBatis XML, SQL Server, Quartz through RuoYi `sys_job`, JUnit 5/Mockito/AssertJ, Vue 2, Element UI, Maven 3.9.11, npm.
## Global Constraints
- Preserve current RuoYi controller/domain/mapper/service/service.impl layering and naming style.
- Do not alter the existing inbound WCS pull flow or non-03 warehouse outbound allocation semantics.
- Apply dense-lane rules only to warehouse `03` cargo spaces with non-empty `group_coding`.
- `in_or_out=23` is relocation and uses `util_serial_task '23'`.
- Automatic WCS retries run at most five times after the initial failed call.
- Relocation source and destination must be in the same warehouse and same floor, but different channel groups.
- All WCS dispatch entry points must enforce current outermost eligibility immediately before the HTTP call.
- Use `D:\BDevTools\maven\apache-maven-3.9.11\bin\mvn.cmd` for Maven commands.
- Use repeatable SQL Server migration statements; do not delete existing business data.
---
### Task 1: Dispatch Outbox Schema And Persistence
**Files:**
- Create: `sql/20260716_dense_outbound_relocation_dispatch.sql`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/dispatch/domain/WcsJobDispatch.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/dispatch/mapper/WcsJobDispatchMapper.java`
- Create: `ruoyi-admin/src/main/resources/mapper/out/WcsJobDispatchMapper.xml`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/out/dispatch/mapper/WcsJobDispatchMapperSqlTest.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/in/job/domain/MydoJob.java`
- Modify: `ruoyi-admin/src/main/resources/mapper/in/MydoJobMapper.xml`
**Interfaces:**
- Produces: `WcsJobDispatchMapper.insertIfAbsent(String jobId)`, `selectByJobId(String jobId)`, `selectRetryCandidates(Date now, int limit)`, `claimForSend(Map<String,Object>)`, `markBlocked(Map)`, `markRetryWait(Map)`, `markSuccess(Map)`, and `markExhausted(Map)`.
- Produces: non-persistent `MydoJob.dispatchStatus`, `autoRetryCount`, `manualRetryCount`, and `lastDispatchError` populated by list/detail queries.
- [ ] **Step 1: Write the failing mapper structure test**
Create a JUnit test that reads the mapper XML and migration and asserts the six statuses, the retry-count predicate, atomic `SENDING` claim, dispatch join aliases, ID `23` seed, Quartz invocation, menu permissions, and repeatable `IF NOT EXISTS` guards.
```java
assertThat(mapperXml)
.contains("dispatch_status = 'SENDING'")
.contains("auto_retry_count &lt; 5")
.contains("last_dispatch_error");
assertThat(migration)
.contains("wcs_job_dispatch")
.contains("wcsOutboundRetryTask.retryFailedJobs")
.contains("out:denseLaneOutbound:generate");
```
- [ ] **Step 2: Run the focused test and verify RED**
Run:
```powershell
& 'D:\BDevTools\maven\apache-maven-3.9.11\bin\mvn.cmd' -pl ruoyi-admin -Dtest=WcsJobDispatchMapperSqlTest test
```
Expected: FAIL because the mapper and migration do not exist.
- [ ] **Step 3: Implement the migration, domain, mapper, XML, and job query join**
Use exact dispatch constants `PENDING`, `SENDING`, `BLOCKED`, `RETRY_WAIT`, `SUCCESS`, `EXHAUSTED`. The migration creates the table and indexes, inserts task type `23` only when absent, inserts the non-concurrent one-minute Quartz row, and inserts/updates the lane-outbound menu and three permissions. `claimForSend` must update exactly one eligible row and increment either `auto_retry_count` or `manual_retry_count` based on a parameter.
- [ ] **Step 4: Run the focused test and verify GREEN**
Run the command from Step 2. Expected: PASS.
- [ ] **Step 5: Commit**
```powershell
git add sql/20260716_dense_outbound_relocation_dispatch.sql ruoyi-admin/src/main/java/com/ruoyi/out/dispatch ruoyi-admin/src/main/resources/mapper/out/WcsJobDispatchMapper.xml ruoyi-admin/src/main/java/com/ruoyi/in/job/domain/MydoJob.java ruoyi-admin/src/main/resources/mapper/in/MydoJobMapper.xml ruoyi-admin/src/test/java/com/ruoyi/out/dispatch/mapper/WcsJobDispatchMapperSqlTest.java
git commit -m "feat: add WCS dispatch outbox"
```
### Task 2: WCS Routing And Durable Dispatch Service
**Files:**
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/dispatch/service/IOutboundDispatchService.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/dispatch/service/impl/OutboundDispatchServiceImpl.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/dispatch/domain/DispatchTrigger.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/dispatch/task/WcsOutboundRetryTask.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DispatchEligibility.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/dense/mapper/DenseLaneMapper.java`
- Create: `ruoyi-admin/src/main/resources/mapper/out/DenseLaneMapper.xml`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/dense/service/IDenseLaneService.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/dense/service/impl/DenseLaneServiceImpl.java`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/out/dispatch/service/impl/OutboundDispatchServiceImplTest.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/api/client/WcsClient.java`
- Modify: `ruoyi-admin/src/main/resources/application.yml`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/in/job/service/IMydoJobService.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/in/job/service/impl/MydoJobServiceImpl.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/out/job/controller/OutMydoJobController.java`
**Interfaces:**
- Produces: `AjaxResult dispatch(String jobId, DispatchTrigger trigger)` where trigger is `INITIAL`, `AUTO_RETRY`, or `MANUAL`.
- Produces: `int retryFailedJobs()` for Quartz.
- Produces: `WcsClient.sendOutboundTask(MydoJob)`, `sendLocationOutboundTask(MydoJob)`, and `sendRelocationTask(MydoJob)`.
- [ ] **Step 1: Write failing service tests**
Cover initial failure with count 0, five auto retries then exhausted, manual send past exhaustion, successful status/phase update, atomic claim rejection, and route selection for types 20, 21, and 23. Mock only mapper and WCS boundaries; assert real service decisions.
```java
assertThat(service.dispatch("CK1", DispatchTrigger.INITIAL).isSuccess()).isTrue();
verify(dispatchMapper).markRetryWait(argThat(m -> Integer.valueOf(0).equals(m.get("autoRetryCount"))));
verify(wcsClient).sendRelocationTask(jobOfType(23L));
```
- [ ] **Step 2: Run the tests and verify RED**
```powershell
& 'D:\BDevTools\maven\apache-maven-3.9.11\bin\mvn.cmd' -pl ruoyi-admin -Dtest=OutboundDispatchServiceImplTest test
```
Expected: FAIL because the dispatch API and WCS methods do not exist.
- [ ] **Step 3: Implement dispatch state transitions, the minimal dense eligibility guard, and WCS routes**
The service creates the outbox row if absent, delegates dense eligibility to `IDenseLaneService`, claims `SENDING`, calls the type route, records failures without throwing away the business job, and updates `mydo_job.status='3'`, `execution_phase='1'`, `start_time` only after success. The minimal dense mapper answers whether the job source is the current outermost occupied cargo position; non-03 or non-dense jobs are immediately eligible. Add `PUT /out/job/pushWcs/{jobId}` as the manual trigger backed by this service. Configure:
```yaml
sendRetrieveTask: ${WCS_OUTBOUND_PATH:/wcs/api/receiveWmsOutJob}
sendLocationOutboundTask: ${WCS_LOCATION_OUTBOUND_PATH:/wcs/api/receiveWmsLocationOutJob}
sendRelocationTask: ${WCS_RELOCATION_PATH:/wcs/api/receiveWmsMoveJob}
```
- [ ] **Step 4: Run the tests and verify GREEN**
Run Step 2. Expected: PASS.
- [ ] **Step 5: Commit**
```powershell
git add ruoyi-admin/src/main/java/com/ruoyi/out/dispatch ruoyi-admin/src/main/java/com/ruoyi/api/client/WcsClient.java ruoyi-admin/src/main/resources/application.yml ruoyi-admin/src/main/java/com/ruoyi/in/job ruoyi-admin/src/main/java/com/ruoyi/out/job/controller/OutMydoJobController.java ruoyi-admin/src/test/java/com/ruoyi/out/dispatch/service/impl/OutboundDispatchServiceImplTest.java
git commit -m "feat: add outbound WCS retry dispatch"
```
### Task 3: Dense Lane Geometry And Eligibility
**Files:**
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLanePosition.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseRelocationCandidate.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/out/dense/mapper/DenseLaneMapper.java`
- Modify: `ruoyi-admin/src/main/resources/mapper/out/DenseLaneMapper.xml`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/out/dense/service/IDenseLaneService.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/out/dense/service/impl/DenseLaneServiceImpl.java`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/out/dense/service/impl/DenseLaneServiceImplTest.java`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/out/dense/mapper/DenseLaneMapperSqlTest.java`
**Interfaces:**
- Produces: `DispatchEligibility checkDispatchEligibility(MydoJob job)`.
- Produces: `List<DenseLanePosition> selectBlockersForUpdate(String cargoSpaceId)` ordered outer-to-inner.
- Produces: `BasCargoSpace selectRelocationTargetForUpdate(RelocationTargetQuery query)`.
- Produces: lane summary, tray preview, and inventory detail queries consumed by Tasks 6 and 7.
- [ ] **Step 1: Write failing direction and SQL tests**
Assert direction 1 treats the maximum occupied column as outermost, direction 2 treats the minimum as outermost, direction 3 uses nearest boundary, non-03 jobs bypass the rule, blockers exclude trays already covered by active outbound jobs, and destination SQL enforces same floor and different `group_coding`.
- [ ] **Step 2: Run focused tests and verify RED**
```powershell
& 'D:\BDevTools\maven\apache-maven-3.9.11\bin\mvn.cmd' -pl ruoyi-admin -Dtest=DenseLaneServiceImplTest,DenseLaneMapperSqlTest test
```
Expected: FAIL because dense-lane classes do not exist.
- [ ] **Step 3: Implement pure ordering helpers and locked mapper queries**
Centralize direction ordering in one service. SQL uses `UPDLOCK,HOLDLOCK` for blocker/destination selection, treats cargo statuses 2/3/4 with tray codes as occupied, excludes active jobs for destination reservations, and joins stock profiles used by current inbound rules.
- [ ] **Step 4: Run tests and verify GREEN**
Run Step 2. Expected: PASS.
- [ ] **Step 5: Commit**
```powershell
git add ruoyi-admin/src/main/java/com/ruoyi/out/dense ruoyi-admin/src/main/resources/mapper/out/DenseLaneMapper.xml ruoyi-admin/src/test/java/com/ruoyi/out/dense
git commit -m "feat: enforce dense lane outermost order"
```
### Task 4: Relocation Job Generation And Completion
**Files:**
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/relocation/domain/RelocationGenerateRequest.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/relocation/domain/RelocationPreview.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/relocation/mapper/RelocationJobMapper.java`
- Create: `ruoyi-admin/src/main/resources/mapper/out/RelocationJobMapper.xml`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/relocation/service/IRelocationJobService.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/relocation/service/impl/RelocationJobServiceImpl.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/relocation/controller/RelocationJobController.java`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/out/relocation/service/impl/RelocationJobServiceImplTest.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/api/controller/ExternalApiController.java`
**Interfaces:**
- Produces: `List<String> createBlockerRelocations(String blockedJobId)`.
- Produces: `RelocationPreview preview(RelocationGenerateRequest request)` and `List<String> generate(RelocationGenerateRequest request)`.
- Produces: `AjaxResult completeFromWcs(String jobId)`.
- [ ] **Step 1: Write failing relocation tests**
Cover type 23/task sequence, one main job per tray, one detail per stock detail, same-floor/different-lane rejection, source status 4/target status 3 reservation, all-or-nothing capacity validation, completion inventory move, cargo status swap, archive, duplicate callback, and dispatch wake-up after commit.
- [ ] **Step 2: Run test and verify RED**
```powershell
& 'D:\BDevTools\maven\apache-maven-3.9.11\bin\mvn.cmd' -pl ruoyi-admin -Dtest=RelocationJobServiceImplTest test
```
Expected: FAIL because relocation service does not exist.
- [ ] **Step 3: Implement transactional generation and completion**
Use `@Transactional(rollbackFor = Exception.class)`. Generate blockers outer-to-inner, reserve all destinations before inserting jobs, copy stock trace fields into job details, and create dispatch rows. On completion, lock and validate active type 23, update every `bas_stock` row for the tray, switch cargo statuses, archive job/detail with explicit column lists, delete active rows, then dispatch the next eligible source-lane job after commit.
- [ ] **Step 4: Route WCS completion by type**
In `ExternalApiController.completeOutbound`, type 23 calls `relocationJobService.completeFromWcs`; all other outbound types keep current phase-5/PDA behavior.
- [ ] **Step 5: Run tests and verify GREEN**
Run Step 2. Expected: PASS.
- [ ] **Step 6: Commit**
```powershell
git add ruoyi-admin/src/main/java/com/ruoyi/out/relocation ruoyi-admin/src/main/resources/mapper/out/RelocationJobMapper.xml ruoyi-admin/src/main/java/com/ruoyi/api/controller/ExternalApiController.java ruoyi-admin/src/test/java/com/ruoyi/out/relocation
git commit -m "feat: add dense warehouse relocation jobs"
```
### Task 5: Integrate Existing Outbound Allocation And Location Outbound
**Files:**
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/out/stockout/service/impl/OutStockoutServiceImpl.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/out/location/service/impl/LocationOutboundServiceImpl.java`
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/out/stockout/service/impl/OutStockoutServiceImplTest.java`
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/out/location/service/impl/LocationOutboundServiceImplTest.java`
**Interfaces:**
- Consumes: relocation blocker generation and unified dispatch from Tasks 2 and 4.
- Produces: allocation responses that distinguish dispatched, queued-for-retry, and blocked-by-relocation jobs.
- [ ] **Step 1: Add failing integration-unit tests**
Assert auto/manual allocation no longer calls `MydoJobService.pushOutJobToWcs`, specified stock allocation creates blocker relocations before dispatch, normal non-03 allocation dispatches directly, location outbound creates relocations for non-outbound full blockers, and WCS failure returns business success with compensation message.
- [ ] **Step 2: Run tests and verify RED**
```powershell
& 'D:\BDevTools\maven\apache-maven-3.9.11\bin\mvn.cmd' -pl ruoyi-admin -Dtest=OutStockoutServiceImplTest,LocationOutboundServiceImplTest test
```
- [ ] **Step 3: Replace direct push calls with prepare-and-dispatch orchestration**
After each successful database allocation, call blocker generation for the selected job, create dispatch rows for every new job, and dispatch only currently eligible jobs. Keep existing non-dense procedure behavior and stock-status filters unchanged.
- [ ] **Step 4: Run tests and verify GREEN**
Run Step 2. Expected: PASS.
- [ ] **Step 5: Commit**
```powershell
git add ruoyi-admin/src/main/java/com/ruoyi/out/stockout/service/impl/OutStockoutServiceImpl.java ruoyi-admin/src/main/java/com/ruoyi/out/location/service/impl/LocationOutboundServiceImpl.java ruoyi-admin/src/test/java/com/ruoyi/out/stockout ruoyi-admin/src/test/java/com/ruoyi/out/location
git commit -m "feat: prepare dense blockers during outbound allocation"
```
### Task 6: KPI Dense Plan Manual Relocation
**Files:**
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/kpi/controller/KpiController.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/kpi/service/KpiService.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/kpi/service/impl/KpiServiceImpl.java`
- Modify: `ruoyi-ui/src/api/kpi/kpi.js`
- Modify: `ruoyi-ui/src/views/kpi/plan/index.vue`
- Modify: `ruoyi-ui/src/views/kpi/plan/components/DenseCargoSpaceMap.vue`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/kpi/service/impl/KpiDenseRelocationServiceTest.java`
**Interfaces:**
- Produces: `GET /kpi/denseRelocation/options`, `POST /kpi/denseRelocation/preview`, and `POST /kpi/denseRelocation/generate`.
- [ ] **Step 1: Write failing controller/service tests**
Assert only warehouse 03 is accepted, target lane floor equals source/current floor, options require sufficient compatible capacity, preview returns exact outer trays and inventory totals, and generate revalidates state.
- [ ] **Step 2: Run Java tests and verify RED**
```powershell
& 'D:\BDevTools\maven\apache-maven-3.9.11\bin\mvn.cmd' -pl ruoyi-admin -Dtest=KpiDenseRelocationServiceTest test
```
- [ ] **Step 3: Implement backend endpoints and existing-style UI**
Add a compact “指定移库” button to the dense toolbar. Use an Element UI dialog with source lane, numeric tray count, target lane, and a fixed-height preview table. Do not add decorative cards or alter map dimensions. Refresh map data after successful generation.
- [ ] **Step 4: Run Java tests and production UI build**
```powershell
& 'D:\BDevTools\maven\apache-maven-3.9.11\bin\mvn.cmd' -pl ruoyi-admin -Dtest=KpiDenseRelocationServiceTest test
Set-Location ruoyi-ui
npm run build:prod
```
Expected: tests PASS and Vue build exits 0.
- [ ] **Step 5: Commit**
```powershell
git add ruoyi-admin/src/main/java/com/ruoyi/kpi ruoyi-admin/src/test/java/com/ruoyi/kpi ruoyi-ui/src/api/kpi/kpi.js ruoyi-ui/src/views/kpi/plan
git commit -m "feat: add KPI dense lane relocation"
```
### Task 7: Dense Lane Outbound Menu And Workflow
**Files:**
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/dense/controller/DenseLaneOutboundController.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLaneOutboundQuery.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLaneOutboundRequest.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLaneSummary.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/dense/service/IDenseLaneOutboundService.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/out/dense/service/impl/DenseLaneOutboundServiceImpl.java`
- Create: `ruoyi-ui/src/api/out/denseLaneOutbound.js`
- Create: `ruoyi-ui/src/views/out/denseLaneOutbound/index.vue`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/out/dense/service/impl/DenseLaneOutboundServiceImplTest.java`
**Interfaces:**
- Produces: `GET /out/denseLaneOutbound/list`, `GET /out/denseLaneOutbound/preview`, and `POST /out/denseLaneOutbound/generate`.
- [ ] **Step 1: Write failing service tests**
Assert list groups only full 03 dense lanes, totals all stock details per tray, preview selects exactly outermost N unreserved trays, quantity over capacity fails, stale preview fails generation, exit compatibility is validated, and every generated type-20 job contains the selected tray's complete inventory details.
- [ ] **Step 2: Run tests and verify RED**
```powershell
& 'D:\BDevTools\maven\apache-maven-3.9.11\bin\mvn.cmd' -pl ruoyi-admin -Dtest=DenseLaneOutboundServiceImplTest test
```
- [ ] **Step 3: Implement backend and menu page**
Use the migration permissions from Task 1. The page displays group code, area, row, floor, full/available tray counts, total stock, current outer tray, and expandable inventory details. Generation dialog uses an integer stepper, exit selector, and preview table showing exact trays and quantities.
- [ ] **Step 4: Verify tests and UI build**
Run Task 6 Step 4 with `DenseLaneOutboundServiceImplTest`. Expected: PASS and build exit 0.
- [ ] **Step 5: Commit**
```powershell
git add ruoyi-admin/src/main/java/com/ruoyi/out/dense ruoyi-admin/src/test/java/com/ruoyi/out/dense ruoyi-ui/src/api/out/denseLaneOutbound.js ruoyi-ui/src/views/out/denseLaneOutbound sql/20260716_dense_outbound_relocation_dispatch.sql
git commit -m "feat: add dense lane outbound workflow"
```
### Task 8: Outbound Job Management Dispatch UI
**Files:**
- Modify: `ruoyi-ui/src/api/out/job.js`
- Modify: `ruoyi-ui/src/views/out/job/index.vue`
- Modify: `sql/20260716_dense_outbound_relocation_dispatch.sql`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/out/job/controller/OutMydoJobDispatchControllerTest.java`
**Interfaces:**
- Consumes: dispatch fields and `PUT /out/job/pushWcs/{jobId}`.
- [ ] **Step 1: Write failing controller permission/state test**
Assert the endpoint requires `out:job:pushWcs`, delegates with `MANUAL`, rejects completed jobs, and returns blocked reason without calling WCS when a dense job is not outermost.
- [ ] **Step 2: Run test and verify RED**
```powershell
& 'D:\BDevTools\maven\apache-maven-3.9.11\bin\mvn.cmd' -pl ruoyi-admin -Dtest=OutMydoJobDispatchControllerTest test
```
- [ ] **Step 3: Implement UI columns and manual action**
Add WCS status, automatic retry count, and last-error tooltip columns. Use the existing send icon button with tooltip “手动下发 WCS”, permission `out:job:pushWcs`, and a confirmation message containing attempts and last error. Show it for pending, retry-wait, exhausted, and blocked active jobs; backend remains authoritative.
- [ ] **Step 4: Verify controller and UI build**
Run Step 2 and `npm run build:prod`. Expected: PASS and build exit 0.
- [ ] **Step 5: Commit**
```powershell
git add ruoyi-admin/src/test/java/com/ruoyi/out/job ruoyi-ui/src/api/out/job.js ruoyi-ui/src/views/out/job/index.vue sql/20260716_dense_outbound_relocation_dispatch.sql
git commit -m "feat: add manual WCS dispatch action"
```
### Task 9: Full Regression, Migration, And Runtime Scenarios
**Files:**
- Create: `docs/testing/2026-07-16-dense-outbound-relocation-dispatch-report.md`
**Interfaces:**
- Verifies all interfaces produced above as one workflow.
- [ ] **Step 1: Run all backend tests**
```powershell
& 'D:\BDevTools\maven\apache-maven-3.9.11\bin\mvn.cmd' -pl ruoyi-admin -am test
```
Expected: BUILD SUCCESS with zero test failures.
- [ ] **Step 2: Run backend package and frontend production build**
```powershell
& 'D:\BDevTools\maven\apache-maven-3.9.11\bin\mvn.cmd' clean package -DskipTests
Set-Location ruoyi-ui
npm run build:prod
```
Expected: both commands exit 0.
- [ ] **Step 3: Apply migration to the configured disposable/local database**
Execute `sql/20260716_dense_outbound_relocation_dispatch.sql` twice. Expected: both runs succeed, one task type 23 row, one Quartz task, one menu, three button permissions, and no duplicate indexes.
- [ ] **Step 4: Run runtime integration scenarios**
Verify with controlled WCS mock responses: initial failure plus five retries reaches exhausted; manual recovery succeeds; direction-1 and direction-2 lanes reject inner jobs; blocker relocation completes source/target inventory atomically; location outbound creates blockers; KPI manual relocation stays same-floor; lane outbound selects exactly outer N; non-03 outbound remains direct.
- [ ] **Step 5: Verify UI with Playwright screenshots**
Check outbound job management, KPI relocation dialog, and dense lane outbound page at desktop and mobile/narrow viewports. Verify no overlap, clipped button text, blank table, or broken dialog sizing.
- [ ] **Step 6: Write the report and run requirement audit**
Record commands, counts, screenshots, created job IDs, retry transitions, relocation source/target IDs, lane outbound selections, and residual risks. Map each of the six user requirements to direct evidence.
- [ ] **Step 7: Commit the verification report**
```powershell
git add docs/testing/2026-07-16-dense-outbound-relocation-dispatch-report.md
git commit -m "test: verify dense outbound dispatch workflows"
```

View File

@@ -0,0 +1,281 @@
# 密集库出库补偿、移库与指定通道作业设计
## 1. 目标
在不改变现有入库流程和非密集库出库规则的前提下,完成以下能力:
1. 出库作业下发 WCS 失败后自动补偿,最多自动重试 5 次,耗尽后支持人工下发。
2. 所有 03 密集库出库与移库作业在下发前都必须是所在通道当前最外侧作业。
3. 指定托盘和指定货位出库遇到遮挡时,自动生成 `in_or_out=23` 的移库作业。
4. KPI 货位平面图支持按通道、数量和目标通道生成同层移库作业。
5. 新增指定通道出库菜单,按通道组查看库存并按端头托盘数量生成出库作业。
6. 入库、普通出库、指定货位/通道出库和移库使用不同的 WCS 接口配置。
## 2. 现状约束
- 后端为 RuoYi Maven 多模块项目,出库作业与入库作业共用 `mydo_job`、`mydo_job_detail`。
- 作业归档过程存在 `INSERT ... SELECT *`,直接给 `mydo_job` 增加字段会同步影响活动表、归档表和多个存储过程。
- 03 仓的一个通道由同一排、同一层、同一通道带的 `group_coding` 标识。
- `bas_cargo_area_config.direction_type` 是入库由深到浅的货位选择方向:方向 `1` 为列号从小到大,方向 `2` 为列号从大到小。
- 当前出库代码错误复用了 `sendPutawayTask` 配置键;本设计改为按作业类型路由。
- 当前 WCS 模拟开关默认开启,真实联调时必须显式关闭。
## 3. 总体架构
采用“业务作业 + WCS 下发记录”的 Outbox 结构:
- `mydo_job` 继续保存业务作业,不增加补偿字段。
- 新表 `wcs_job_dispatch` 与作业一一关联,保存下发状态、重试次数、错误、时间和并发版本。
- `OutboundDispatchService` 是所有出库、指定货位、指定通道和移库作业下发 WCS 的唯一入口。
- `DenseLaneService` 负责密集库通道顺序、遮挡托盘、最外侧资格和移库目标货位选择。
- `RelocationJobService` 负责自动/人工移库作业生成和 WCS 完成后的库存搬移。
- Quartz 每分钟调用 `WcsOutboundRetryTask.retryFailedJobs()` 执行自动补偿。
所有入口统一调用同一套下发资格检查,前端按钮、定时重试和分配后即时下发不能绕过密集库最外侧规则。
## 4. WCS 下发记录
新增表 `wcs_job_dispatch`:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `job_id` | nvarchar(255) PK | 对应作业 ID |
| `dispatch_status` | nvarchar(20) | `PENDING`、`SENDING`、`BLOCKED`、`RETRY_WAIT`、`SUCCESS`、`EXHAUSTED` |
| `auto_retry_count` | int | 已执行的自动重试次数,范围 0 至 5 |
| `manual_retry_count` | int | 人工下发次数 |
| `last_error` | nvarchar(1000) | 最后一次失败或等待原因 |
| `last_attempt_time` | datetime2 | 最后一次实际调用 WCS 时间 |
| `next_retry_time` | datetime2 | 下次允许自动重试时间 |
| `success_time` | datetime2 | 下发成功时间 |
| `version_no` | int | 并发抢占版本 |
| `create_time` | datetime2 | 创建时间 |
| `update_time` | datetime2 | 更新时间 |
初次下发失败不计入自动重试次数,状态设为 `RETRY_WAIT`、`auto_retry_count=0`。定时任务每调用一次前递增自动重试次数,失败达到 5 次后设为 `EXHAUSTED`。人工下发只递增 `manual_retry_count`,不重置自动重试次数,也不会重新进入自动队列。
密集库作业因不是当前最外侧而未调用 WCS 时,不计失败、不增加重试次数,记录统一设为 `BLOCKED` 并写入等待原因。外侧作业完成后立即唤醒同通道 `BLOCKED` 记录;定时任务每分钟复查一次 `BLOCKED` 记录作为异常恢复兜底。
通过原子状态更新把记录抢占为 `SENDING`,防止定时任务、人工按钮和业务线程并发重复下发。超过发送超时时间仍为 `SENDING` 的记录允许重新进入补偿流程。WCS 以 `taskNo` 作为幂等键;重复报文必须返回同一任务的成功确认。
## 5. 下发状态流转
### 5.1 分配后即时下发
1. 出库分配或作业生成事务提交。
2. 创建 `PENDING` 下发记录。
3. 调用统一下发服务。
4. 资格不满足:转为 `BLOCKED`,业务接口返回作业已生成、等待外侧作业完成。
5. WCS 调用失败:记录 `RETRY_WAIT`,业务接口返回作业已生成、WCS 下发失败并进入补偿队列。
6. WCS 调用成功:记录 `SUCCESS`,作业状态改为 `3`,执行阶段改为 `1`,写入开始时间。
分配成功后不因 WCS 网络失败回滚库存预占和作业生成,避免操作员重复分配。数据库分配失败仍按现有事务回滚。
### 5.2 自动重试
- Quartz 每分钟扫描 `RETRY_WAIT` 且 `auto_retry_count < 5`、`next_retry_time <= 当前时间` 的记录,同时复查 `BLOCKED` 作业是否已经成为最外侧。
- 每批按创建时间处理,单批数量可配置,默认 100。
- 重新执行作业存在性、作业状态、密集库最外侧和 WCS 路由检查。
- 网络或业务失败采用固定 1 分钟间隔;第 5 次失败转为 `EXHAUSTED`。
- `BLOCKED` 复查和最外侧资格不满足都不消耗重试次数。
### 5.3 人工下发
- 出库作业管理对 `PENDING`、`RETRY_WAIT`、`EXHAUSTED` 的未完成作业显示“手动下发 WCS”。
- 操作前弹出确认框,展示自动重试次数和最后错误。
- 人工下发仍执行密集库最外侧校验。
- 失败后保持原自动状态或 `EXHAUSTED`,更新错误和人工次数;成功后转为 `SUCCESS`。
## 6. 密集库最外侧规则
仅仓库 `03` 且货位存在非空 `group_coding` 时应用密集库规则。
- 同通道范围:`warehouse_id + group_coding`,`group_coding` 已包含排和层。
- 方向 `1`:入库从小列到大列,当前最外侧为所有占用/预占货位中的最大列。
- 方向 `2`:入库从大列到小列,当前最外侧为所有占用/预占货位中的最小列。
- 方向 `3`:按距离两侧边界较近者优先,同距离取较小列。
- 占用范围包括状态 `2`(满)、`3`(入库分配)、`4`(出库/移库分配)且托盘码非空的货位。
资格检查以事务中的实时货位数据为准,不能使用页面缓存。若同一最外侧托盘已经存在状态 `2/3` 的活动作业,只允许该作业继续,其他作业等待。
## 7. 遮挡处理与自动移库
### 7.1 触发范围
- 出库单分配明确指定库存明细、库存主记录或托盘时。
- 指定货位出库选择 03 仓密集库货位时。
- 指定托盘前存在其他占用托盘时。
### 7.2 遮挡托盘判定
按第 6 节的出库顺序,位于目标托盘外侧的占用托盘均为遮挡托盘。
- 遮挡托盘已有活动出库作业时,不再生成移库作业;该出库作业自然成为前置动作。
- 遮挡托盘没有活动出库/移库作业时,按从外到内顺序生成移库作业。
- 目标出库作业可以同时生成,但只有所有外侧货位释放后才具备下发资格。
### 7.3 自动移库目标
目标货位必须满足:
- 同仓库、同层、不同 `group_coding`。
- 状态为 `1`、未锁定、无托盘码。
- 符合托盘类型允许仓库和货位类型规则。
- 符合原通道的存储模式、项目号或物料相容规则。
- 不得被其他活动作业预占。
候选通道优先沿用现有入库规则:优先已有相容库存的通道,再按占用数量和通道编码选择;通道内按入库方向选择最深可用货位。生成作业后将源货位置为 `4`,目标货位置为 `3` 并预留托盘码。
## 8. 移库作业
- `in_or_out=23`,任务号调用 `util_serial_task '23'`。
- 主作业的 `cargo_space_id` 为源货位,`target_cargo_space_id` 为目标货位。
- 每个托盘生成一个移库主作业;按库存明细生成对应 `mydo_job_detail`,保留库存、批次、项目号和数量追溯。
- 移库作业使用独立 WCS 路由,不进入 PDA 出库确认列表。
WCS 完成移库回调后,在一个数据库事务内:
1. 锁定移库作业、源货位、目标货位和托盘库存。
2. 校验作业类型为 23、作业已下发、源目标仍与作业一致。
3. 将该托盘所有 `bas_stock.cargo_space_id` 更新为目标货位。
4. 源货位置为 `1` 并清空托盘码,目标货位置为 `2` 并保留托盘码。
5. 作业及明细归档并从活动表删除。
6. 事务提交后触发同源通道下一条等待作业的资格检查和即时下发。
重复完成回调通过活动/归档作业查询实现幂等,已完成时返回成功。
## 9. KPI 货位平面图指定移库
仅在 03 密集库视图显示“指定移库”命令。弹窗包含:
- 当前层,只读。
- 源通道选择,只列出当前层有满货位的通道。
- 移库托盘数量,范围 1 至源通道当前可移动托盘数。
- 目标通道选择,只列出当前层具有足够相容空位的其他通道。
- 外侧托盘预览,展示托盘码、源货位、库存明细和总数量。
提交时后端重新锁定并验证通道、层、数量、库存与目标容量,按外到内选择源托盘并生成移库作业。页面数据只用于预览,不作为作业生成依据。
## 10. 指定货位出库
现有非密集库逻辑保持不变。03 密集库指定货位出库调整为:
1. 锁定目标货位和通道。
2. 计算外侧遮挡托盘。
3. 为没有活动出库作业的遮挡托盘生成移库作业。
4. 生成目标托盘的指定货位出库作业及下发记录。
5. 先尝试下发当前最外侧移库/出库作业;目标作业等待通道逐步清空。
## 11. 指定通道出库
在出库管理下新增“指定通道出库”菜单和权限:
- `out:denseLaneOutbound:list`
- `out:denseLaneOutbound:preview`
- `out:denseLaneOutbound:generate`
列表仅查询 03 仓 `group_coding` 非空且至少存在一个状态 `2` 满货位的通道,展示:
- 通道组编码、货区、排、层和方向。
- 满托盘数量、可出库托盘数量、库存总数量。
- 聚合库存摘要和可展开库存明细。
- 当前最外侧托盘及货位。
生成弹窗要求选择整托数量和适配出库口。后端按当前外侧顺序选择前 N 个未被活动作业占用的托盘,返回预览供确认,确认后每托生成一个指定出库作业和完整作业明细。任何托盘状态变化都会使旧预览失效,提交时重新校验并返回明确冲突信息。
## 12. WCS 路由
配置增加独立键并允许通过环境变量覆盖:
```yaml
external:
systems:
WCS:
apis:
sendPutawayTask: ${WCS_PUTAWAY_PATH:/wcs/api/receiveWmsJob}
sendRetrieveTask: ${WCS_OUTBOUND_PATH:/wcs/api/receiveWmsOutJob}
sendLocationOutboundTask: ${WCS_LOCATION_OUTBOUND_PATH:/wcs/api/receiveWmsLocationOutJob}
sendRelocationTask: ${WCS_RELOCATION_PATH:/wcs/api/receiveWmsMoveJob}
```
路由规则:
- `in_or_out < 20`:`sendPutawayTask`。
- `in_or_out = 20`:`sendLocationOutboundTask`,覆盖指定货位和指定通道整托出库。
- `in_or_out = 23`:`sendRelocationTask`。
- 其他 `in_or_out >= 20`:`sendRetrieveTask`。
所有接口继续按作业所属仓库解析独立 WCS 根地址。
## 13. UI 设计
沿用现有 Vue 2、Element UI 和 RuoYi 页面密度:
- 出库作业列表增加“WCS 下发状态”“自动重试”“最后错误”列;错误使用 tooltip,避免撑高表格。
- “手动下发 WCS”使用发送图标和确认对话框,仅对允许状态显示。
- KPI 平面图在密集库工具栏增加移库命令,使用紧凑表单弹窗,不改变平面图颜色体系。
- 指定通道出库采用筛选栏、通道表格和单层生成弹窗;库存明细使用表格展开行,不嵌套装饰卡片。
## 14. 数据库迁移
新增一份可重复执行的 SQL Server 迁移脚本,包含:
- 创建 `wcs_job_dispatch`、状态/重试索引和字段说明。
- 确认 `bas_task_no` 存在 ID `23`,存在则保留当前序号,不存在则补充移库类型。
- 新增指定通道出库菜单、按钮权限和角色授权兼容处理。
- 新增 Quartz 任务记录,调用 `wcsOutboundRetryTask.retryFailedJobs`,Cron 为每分钟一次,禁止并发。
- 为密集库通道、活动作业和下发扫描补充必要索引,创建前检查同名索引。
迁移不修改或删除现有业务数据,可重复执行。
## 15. 错误处理
- WCS 网络失败、超时、非成功响应和报文序列化失败均写入下发记录。
- 最外侧不满足、前置作业未完成和目标货位状态变化属于等待/并发冲突,不消耗自动重试次数。
- 自动移库目标不足时,原出库分配整体失败,不生成部分移库作业。
- 人工指定移库目标容量或相容性不足时,整个请求回滚。
- 移库完成时源目标或库存不一致,事务回滚并返回明确错误,不做部分库存搬移。
- 菜单生成作业时预览已过期,返回刷新提示,不自动替换为其他托盘。
## 16. 测试与验收
### 16.1 后端单元与 Mapper 测试
- 首次失败进入补偿队列且自动次数为 0。
- 自动重试恰好最多执行 5 次,第 5 次失败后耗尽。
- 人工下发超过 5 次仍可调用,但不会恢复自动队列。
- 调度并发抢占只有一个调用者成功。
- 非最外侧作业不调用 WCS且不增加重试次数。
- 方向 1、方向 2 的最外侧和遮挡集合正确。
- 指定托盘/货位遮挡时按外到内生成类型 23 作业。
- 自动目标同层、不同通道、相容且按入库规则选深位。
- 移库完成原子更新库存、源目标货位并归档。
- 指定通道预览与生成只选择端头 N 个托盘。
- `20`、`23` 和普通出库分别命中正确 WCS 配置键。
### 16.2 前端验证
- 出库作业列表正确显示状态、次数、错误和人工按钮权限。
- KPI 指定移库只能选择同层目标通道,数量和容量校验提示明确。
- 指定通道出库列表、展开明细、预览数量和生成确认可用。
- 生产构建通过,桌面和常用窄屏下表格、弹窗无文字遮挡。
### 16.3 集成场景
1. 构造 WCS 连续失败,验证初次调用加 5 次自动重试后耗尽。
2. 人工恢复下发,验证成功状态和作业状态更新。
3. 在方向 1 和方向 2 通道各构造三托遮挡,验证下发顺序为外到内。
4. 指定最内托盘出库,验证先生成并完成两个移库作业,再下发目标出库。
5. KPI 指定跨通道同层移库,验证库存和货位一致。
6. 指定通道出库 N 托,验证只选择端头 N 托及其完整库存。
7. 验证非 03 仓原有自动、手动和指定货位出库不受影响。
## 17. 需求覆盖
| 原需求 | 设计章节 |
| --- | --- |
| 失败标记、5 次重试、定时器、人工下发、最外侧校验 | 4、5、6 |
| 指定托盘遮挡自动移库,类型 23 | 7、8 |
| KPI 平面图指定同层移库 | 9 |
| 出库 WCS 地址区分 | 12 |
| 指定货位出库遮挡移库 | 10 |
| 指定通道出库菜单与端头 N 托 | 11 |

View File

@@ -0,0 +1,135 @@
# 密集库出库、移库与 WCS 下发验收报告
## 验收结论
本次实现已完成代码级回归、生产构建、数据库迁移幂等验证、真实只读接口验证和浏览器页面验证。后端共执行 235 个测试,失败、错误和跳过均为 0;后端打包和前端生产构建均成功。
运行期验证使用本地 FTC25009-WMS 数据库和重新打包启动的后端,仅执行查询、预览、登录和页面访问,没有提交出库或移库作业,避免影响现有库存。
## 验证环境
- 日期:2026-07-16
- 分支:`codex/dense-outbound-relocation`
- Java:17
- Maven:`D:\BDevTools\maven\apache-maven-3.9.11\bin\mvn.cmd`
- 后端:`http://127.0.0.1:8080`
- 前端:`http://127.0.0.1`
- 数据库:FTC25009-WMS
## 自动化测试与构建
### 后端全量测试
```powershell
& 'D:\BDevTools\maven\apache-maven-3.9.11\bin\mvn.cmd' -pl ruoyi-admin -am test
```
结果:`BUILD SUCCESS`,`Tests run: 235, Failures: 0, Errors: 0, Skipped: 0`。
覆盖的核心测试包括:
- `OutboundDispatchServiceImplTest`:初次失败、最多 5 次自动重试、人工下发、并发抢占、密集库阻塞和类型路由。
- `DenseLaneServiceImplTest`、`DenseLaneMapperSqlTest`:端头顺序、遮挡集合、同层不同通道目标、显式字段映射。
- `RelocationJobServiceImplTest`、`RelocationJobMapperSqlTest`:类型 23 作业、准确预览汇总、库存搬移、货位切换、归档与真实供应商字段。
- `OutStockoutServiceImplTest`、`LocationOutboundServiceImplTest`:指定库存和指定货位遇到遮挡时的移库编排。
- `KpiDenseRelocationServiceTest`:03 仓、同层目标、容量、预览与生成复核。
- `DenseLaneOutboundServiceImplTest`:端头 N 托、完整库存明细、准确货位汇总、预览签名失效和出库口适配。
- `WcsClientTest`、`OutMydoJobDispatchControllerTest`:独立 WCS 地址和人工下发接口。
### 后端打包
```powershell
& 'D:\BDevTools\maven\apache-maven-3.9.11\bin\mvn.cmd' clean package -DskipTests
```
结果:`BUILD SUCCESS`。打包前停止了占用 JAR 的本地后端,完成后使用新 JAR 重新启动,并通过 `/captchaImage` 返回 HTTP 200 验证服务恢复。
### 前端生产构建
```powershell
Set-Location ruoyi-ui
npm run build:prod
```
结果:构建成功并生成 `dist`。存在项目原有的包体积提示,没有编译错误。
## 数据库迁移
连续执行两次:
```powershell
& '.\scripts\apply-sql-file.ps1' -SqlFile 'sql\20260716_dense_outbound_relocation_dispatch.sql'
& '.\scripts\apply-sql-file.ps1' -SqlFile 'sql\20260716_dense_outbound_relocation_dispatch.sql'
```
两次均成功。执行后计数如下:
| 对象 | 数量 |
| --- | ---: |
| `wcs_job_dispatch` 表 | 1 |
| `IX_wcs_job_dispatch_retry` 索引 | 1 |
| `IX_mydo_job_dense_dispatch` 索引 | 1 |
| `bas_task_no` 类型 23 | 1 |
| WCS 补偿 Quartz 任务 | 1 |
| 指定通道出库菜单权限 | 1 |
| 通道出库预览权限 | 1 |
| 通道出库生成权限 | 1 |
| 出库作业人工下发权限 | 1 |
## 真实接口验证
在新打包后端上登录并调用只读接口,得到以下结果:
| 项目 | 结果 |
| --- | --- |
| 03 仓指定通道列表 | 12 条 |
| 通道编码为空的记录 | 0 条 |
| 验证通道 | `03010101` |
| 端头预览 | 1 托 |
| 预览签名 | 存在 |
| 预览合计库存 | 1 |
| 库存明细合计 | 1 |
| KPI 同层移库源通道 | 7 条 |
| KPI 同层移库目标通道 | 44 条 |
预览合计与准确选中货位的库存明细合计一致,避免重复托盘码导致跨货位重复汇总。
## 页面验证
执行:
```powershell
$env:WMS_TEST_USERNAME='admin'
$env:WMS_TEST_PASSWORD='admin123'
python scripts\verify_dense_outbound_ui.py
```
结果:退出码 0,浏览器控制台错误 0,请求失败 0。桌面视口宽度 1440px 和窄屏视口宽度 390px 的页面文档宽度均等于视口宽度,没有页面级横向溢出。
截图:
- [出库作业 WCS 状态与人工下发](screenshots/2026-07-16-dense-outbound/outbound-job-desktop.png)
- [指定通道出库列表](screenshots/2026-07-16-dense-outbound/dense-lane-outbound-desktop.png)
- [真实数据出库预览弹窗](screenshots/2026-07-16-dense-outbound/dense-lane-outbound-dialog-real.png)
- [只读模拟数据完整弹窗](screenshots/2026-07-16-dense-outbound/dense-lane-outbound-dialog.png)
- [指定通道出库窄屏](screenshots/2026-07-16-dense-outbound/dense-lane-outbound-narrow.png)
- [KPI 货位平面图与指定移库](screenshots/2026-07-16-dense-outbound/kpi-plan-desktop.png)
真实弹窗截图在预览加载遮罩消失且托盘行渲染完成后生成,能看到准确托盘、货位和库存数量。
## 六项需求映射
| 需求 | 实现与直接证据 |
| --- | --- |
| WCS 失败补偿、最多 5 次自动重试、人工下发、最外侧校验 | Outbox 状态机、Quartz 任务、统一下发服务;`OutboundDispatchServiceImplTest` 15 项通过,出库作业页面验证通过。 |
| 指定托盘被遮挡时自动生成类型 23 移库 | 出库分配调用遮挡移库编排;`OutStockoutServiceImplTest`、`RelocationJobServiceImplTest` 通过。 |
| KPI 平面图指定同层移库 | 同仓同层、不同通道及容量复核;KPI 测试通过,真实选项为源 7、目标 44,页面正常。 |
| 入库、普通出库、指定货位/通道、移库分别使用 WCS 地址 | 按 `in_or_out` 路由到 putaway、retrieve、location-outbound、relocation;`WcsClientTest` 和 dispatch 路由测试通过。 |
| 指定货位出库被遮挡时先生成移库 | `LocationOutboundServiceImpl` 接入统一遮挡编排;对应服务测试通过。 |
| 指定通道按端头 N 托整托出库 | 列表、库存明细、准确预览签名、出库口适配和生成流程已实现;真实预览 1 托且合计一致,桌面与窄屏页面验证通过。 |
## 边界说明
- 本轮没有在现有业务库提交真实出库、移库或 WCS 失败作业;状态流转、库存搬移和失败补偿由自动化测试验证。
- 真实 WCS 联调仍需在可控测试单据和 WCS 测试地址下执行,避免向生产设备下发任务。
- 前端生产构建只有现有项目的包体积警告,不影响本次功能验收。

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -86,13 +86,27 @@ public class WcsClient extends BaseExternalClient {
}
try {
String body = objectMapper.writeValueAsString(job);
// 正式WCS作业对象仍使用统一接收入口;地址按出库作业所属仓库解析。
return post(jobUrl("sendPutawayTask", job), body, "WCS-下发出库任务", this::parseWcsAck, MsgTypeEnum.JSON);
return post(jobUrl("sendRetrieveTask", job), body, "WCS-下发出库任务", this::parseWcsAck, MsgTypeEnum.JSON);
} catch (Exception e) {
return ApiResponse.fail("SERIALIZE_FAIL", "出库作业JSON报文生成失败: " + e.getMessage());
}
}
/** 兼容统一出库命名,实际使用普通出库接口。 */
public ApiResponse<String> sendOutboundTask(MydoJob job) {
return sendRetrieveTask(job);
}
/** 下发指定货位出库任务。 */
public ApiResponse<String> sendLocationOutboundTask(MydoJob job) {
return sendJsonJob(job, "sendLocationOutboundTask", "WCS-下发指定货位出库任务", "MOCK_WCS_LOCATION_OUT_OK");
}
/** 下发密集库移库任务。 */
public ApiResponse<String> sendRelocationTask(MydoJob job) {
return sendJsonJob(job, "sendRelocationTask", "WCS-下发移库任务", "MOCK_WCS_RELOCATION_OK");
}
// ── 接口3:查询设备状态 ───────────────────────────────────
public ApiResponse<List<Map<String, String>>> queryDeviceStatus(String deviceType) {
@@ -161,6 +175,21 @@ public class WcsClient extends BaseExternalClient {
return config().getApiUrl(apiKey, job == null ? null : job.getWarehouseId());
}
private ApiResponse<String> sendJsonJob(MydoJob job, String apiKey, String description, String mockResult) {
if (job == null) {
return ApiResponse.fail("PARAM_ERROR", "作业不能为空");
}
if (mockEnabled()) {
return ApiResponse.ok(mockResult);
}
try {
String body = objectMapper.writeValueAsString(job);
return post(jobUrl(apiKey, job), body, description, this::parseWcsAck, MsgTypeEnum.JSON);
} catch (Exception e) {
return ApiResponse.fail("SERIALIZE_FAIL", "作业JSON报文生成失败: " + e.getMessage());
}
}
private String parseOkNg(String rawBody) {
String[] parts = rawBody.trim().split("\\|", 2);
if ("OK".equals(parts[0])) {

View File

@@ -14,6 +14,7 @@ import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.in.job.domain.MydoJob;
import com.ruoyi.in.job.service.IMydoJobService;
import com.ruoyi.out.relocation.service.IRelocationJobService;
import com.ruoyi.pda.service.PdaService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.BeanUtils;
@@ -37,6 +38,7 @@ public class ExternalApiController extends BaseController {
@Autowired private MesClient mesClient;
@Autowired private WcsClient wcsClient;
@Autowired private IMydoJobService mydoJobService;
@Autowired private IRelocationJobService relocationJobService;
@Autowired private PdaService pdaService;
// ═══════════════ ERP ═══════════════
@@ -353,11 +355,20 @@ public class ExternalApiController extends BaseController {
job = mydoJobService.selectLatestOutMydoJobByTrayCodeAndWarehouseId(request.getTrayCode().trim(), request.getWarehouseId().trim());
}
if (job == null) {
if (request != null && request.getJobId() != null && !request.getJobId().trim().isEmpty()) {
AjaxResult relocationResult = relocationJobService.completeFromWcs(request.getJobId().trim());
if (relocationResult != null && relocationResult.isSuccess()) {
return wcsResult(relocationResult);
}
}
return wcsError("未找到对应出库作业");
}
if (!isExpectedJobType(job, false)) {
return wcsError("非法出库作业");
}
if (Long.valueOf(23L).equals(job.getInOrOut())) {
return wcsResult(relocationJobService.completeFromWcs(job.getJobId()));
}
return wcsResult(mydoJobService.completeOutJobFromWcs(job.getJobId()));
}

View File

@@ -140,6 +140,18 @@ public class MydoJob extends BaseEntity
/** 通道匹配物料名称(非持久化字段) */
private String productName;
/** WCS 下发状态(非持久化字段) */
private String dispatchStatus;
/** WCS 自动重试次数(非持久化字段) */
private Integer autoRetryCount;
/** WCS 人工下发次数(非持久化字段) */
private Integer manualRetryCount;
/** WCS 最后一次下发错误(非持久化字段) */
private String lastDispatchError;
public void setJobId(String jobId)
{
this.jobId = jobId;
@@ -490,6 +502,46 @@ public class MydoJob extends BaseEntity
this.productName = productName;
}
public String getDispatchStatus()
{
return dispatchStatus;
}
public void setDispatchStatus(String dispatchStatus)
{
this.dispatchStatus = dispatchStatus;
}
public Integer getAutoRetryCount()
{
return autoRetryCount;
}
public void setAutoRetryCount(Integer autoRetryCount)
{
this.autoRetryCount = autoRetryCount;
}
public Integer getManualRetryCount()
{
return manualRetryCount;
}
public void setManualRetryCount(Integer manualRetryCount)
{
this.manualRetryCount = manualRetryCount;
}
public String getLastDispatchError()
{
return lastDispatchError;
}
public void setLastDispatchError(String lastDispatchError)
{
this.lastDispatchError = lastDispatchError;
}
@Override
public String toString() {
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
@@ -529,6 +581,10 @@ public class MydoJob extends BaseEntity
.append("projectNo", getProjectNo())
.append("productId", getProductId())
.append("productName", getProductName())
.append("dispatchStatus", getDispatchStatus())
.append("autoRetryCount", getAutoRetryCount())
.append("manualRetryCount", getManualRetryCount())
.append("lastDispatchError", getLastDispatchError())
.toString();
}
}

View File

@@ -23,6 +23,8 @@ import com.ruoyi.in.jobdetail.mapper.MydoJobDetailMapper;
import com.ruoyi.in.stockin.mapper.InStockinMapper;
import com.ruoyi.out.stockout.mapper.OutStockoutMapper;
import com.ruoyi.out.stockoutdetail.mapper.OutStockoutDetailMapper;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
import com.ruoyi.out.dispatch.service.IOutboundDispatchService;
import com.ruoyi.pd.diff.mapper.PdDifferenceRecordMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
@@ -78,6 +80,9 @@ public class MydoJobServiceImpl implements IMydoJobService
@Autowired
private PdDifferenceRecordMapper pdDifferenceRecordMapper;
@Autowired
private IOutboundDispatchService outboundDispatchService;
/**
* 查询作业信息
*
@@ -309,38 +314,7 @@ public class MydoJobServiceImpl implements IMydoJobService
@Override
public AjaxResult pushOutJobToWcs(String jobId)
{
if (StringUtils.isEmpty(jobId)) {
return AjaxResult.error("作业ID不能为空");
}
MydoJob job = mydoJobMapper.selectMydoJobByJobId(jobId);
if (job == null) {
return AjaxResult.error("出库作业不存在");
}
if (job.getInOrOut() == null || job.getInOrOut() < 20) {
return AjaxResult.error("请选择出库作业");
}
if (!"1".equals(job.getStatus())) {
if ("2".equals(job.getStatus()) || "3".equals(job.getStatus())) {
return AjaxResult.success("出库作业已下发,无需重复推送");
}
return AjaxResult.error("当前作业状态不允许下发WCS");
}
ApiResponse<String> response = wcsClient.sendRetrieveTask(job);
if (response == null || !response.isSuccess()) {
String errorMessage = response == null ? "WCS接口无返回" : response.getMessage();
return AjaxResult.error("WCS出库作业下发失败:" + errorMessage);
}
MydoJob updateJob = new MydoJob();
updateJob.setJobId(job.getJobId());
updateJob.setStatus("3");
updateJob.setExecutionPhase("1");
updateJob.setStartTime(DateUtils.getTime());
if (mydoJobMapper.updateMydoJob(updateJob) <= 0) {
return AjaxResult.error("出库作业状态更新失败");
}
return AjaxResult.success("WCS出库作业下发成功:" + Convert.toStr(response.getData()));
return outboundDispatchService.dispatch(jobId, DispatchTrigger.INITIAL);
}
/**

View File

@@ -10,6 +10,8 @@ import com.ruoyi.kpi.service.KpiService;
import org.springframework.beans.factory.annotation.Autowired;
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;
@@ -63,6 +65,27 @@ public class KpiController extends BaseController
return AjaxResult.success(kpiService.getDenseCargoSpacePlanData(warehouseId));
}
@GetMapping("/denseRelocation/options")
@PreAuthorize("@ss.hasPermi('kpi:cargo:space:plan')")
public AjaxResult denseRelocationOptions(
com.ruoyi.out.relocation.domain.RelocationGenerateRequest request) {
return AjaxResult.success(kpiService.getDenseRelocationOptions(request));
}
@PostMapping("/denseRelocation/preview")
@PreAuthorize("@ss.hasPermi('kpi:cargo:space:plan')")
public AjaxResult previewDenseRelocation(
@RequestBody com.ruoyi.out.relocation.domain.RelocationGenerateRequest request) {
return AjaxResult.success(kpiService.previewDenseRelocation(request));
}
@PostMapping("/denseRelocation/generate")
@PreAuthorize("@ss.hasPermi('kpi:cargo:space:plan')")
public AjaxResult generateDenseRelocation(
@RequestBody com.ruoyi.out.relocation.domain.RelocationGenerateRequest request) {
return AjaxResult.success(kpiService.generateDenseRelocation(request));
}
/**
* 货位平面图-货位详情
*/

View File

@@ -63,4 +63,13 @@ public interface KpiService
* @return 包含 msg 的Map
*/
Map<String, Object> getCargoSpaceMag(String warehouseId, String thePlatoon, String theColumn, String theFloor);
Map<String, Object> getDenseRelocationOptions(
com.ruoyi.out.relocation.domain.RelocationGenerateRequest request);
com.ruoyi.out.relocation.domain.RelocationPreview previewDenseRelocation(
com.ruoyi.out.relocation.domain.RelocationGenerateRequest request);
List<String> generateDenseRelocation(
com.ruoyi.out.relocation.domain.RelocationGenerateRequest request);
}

View File

@@ -14,6 +14,11 @@ import com.ruoyi.in.job.mapper.MydoJobMapper;
import com.ruoyi.kpi.domain.*;
import com.ruoyi.kpi.mapper.KpiMapper;
import com.ruoyi.kpi.service.KpiService;
import com.ruoyi.out.dense.domain.DenseLaneOption;
import com.ruoyi.out.dense.mapper.DenseLaneMapper;
import com.ruoyi.out.relocation.domain.RelocationGenerateRequest;
import com.ruoyi.out.relocation.domain.RelocationPreview;
import com.ruoyi.out.relocation.service.IRelocationJobService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -43,6 +48,12 @@ public class KpiServiceImpl implements KpiService {
@Autowired
private MydoJobMapper mydoJobMapper;
@Autowired
private DenseLaneMapper denseLaneMapper;
@Autowired
private IRelocationJobService relocationJobService;
private static final ObjectMapper objectMapper = new ObjectMapper();
private static final String UNASSIGNED_ROADWAY = "未分配";
@@ -420,4 +431,54 @@ public class KpiServiceImpl implements KpiService {
result.put("msg", msg.toString());
return result;
}
@Override
public Map<String, Object> getDenseRelocationOptions(RelocationGenerateRequest request)
{
validateDenseRelocationBase(request);
List<DenseLaneOption> options = denseLaneMapper.selectLaneOptions(
request.getWarehouseId(), request.getTheFloor());
if (options == null) {
options = Collections.emptyList();
}
int requiredCapacity = request.getTrayCount() == null || request.getTrayCount() <= 0
? 1 : request.getTrayCount();
List<DenseLaneOption> sourceLanes = options.stream()
.filter(item -> item.getFullTrayCount() != null && item.getFullTrayCount() > 0)
.collect(java.util.stream.Collectors.toList());
List<DenseLaneOption> targetLanes = options.stream()
.filter(item -> !StringUtils.hasText(request.getSourceGroupCoding())
|| !request.getSourceGroupCoding().equals(item.getGroupCoding()))
.filter(item -> item.getEmptyLocationCount() != null
&& item.getEmptyLocationCount() >= requiredCapacity)
.collect(java.util.stream.Collectors.toList());
Map<String, Object> result = new LinkedHashMap<>();
result.put("sourceLanes", sourceLanes);
result.put("targetLanes", targetLanes);
return result;
}
@Override
public RelocationPreview previewDenseRelocation(RelocationGenerateRequest request)
{
validateDenseRelocationBase(request);
return relocationJobService.preview(request);
}
@Override
public List<String> generateDenseRelocation(RelocationGenerateRequest request)
{
validateDenseRelocationBase(request);
return relocationJobService.generate(request);
}
private void validateDenseRelocationBase(RelocationGenerateRequest request)
{
if (request == null || !"03".equals(request.getWarehouseId())) {
throw new RuntimeException("指定移库仅支持03仓密集库");
}
if (request.getTheFloor() == null) {
throw new RuntimeException("指定移库必须选择当前层");
}
}
}

View File

@@ -0,0 +1,49 @@
package com.ruoyi.out.dense.controller;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.out.dense.domain.DenseLaneOutboundQuery;
import com.ruoyi.out.dense.domain.DenseLaneOutboundRequest;
import com.ruoyi.out.dense.service.IDenseLaneOutboundService;
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;
/**
* 指定密集库通道出库接口。
*/
@RestController
@RequestMapping("/out/denseLaneOutbound")
public class DenseLaneOutboundController extends BaseController
{
private final IDenseLaneOutboundService service;
public DenseLaneOutboundController(IDenseLaneOutboundService service)
{
this.service = service;
}
@PreAuthorize("@ss.hasPermi('out:denseLaneOutbound:list')")
@GetMapping("/list")
public AjaxResult list(DenseLaneOutboundQuery query)
{
return success(service.list(query));
}
@PreAuthorize("@ss.hasPermi('out:denseLaneOutbound:preview')")
@GetMapping("/preview")
public AjaxResult preview(DenseLaneOutboundRequest request)
{
return success(service.preview(request));
}
@PreAuthorize("@ss.hasPermi('out:denseLaneOutbound:generate')")
@PostMapping("/generate")
public AjaxResult generate(@RequestBody DenseLaneOutboundRequest request)
{
return success(service.generate(request));
}
}

View File

@@ -0,0 +1,35 @@
package com.ruoyi.out.dense.domain;
import java.math.BigDecimal;
/**
* 密集库通道移库选项。
*/
public class DenseLaneOption
{
private String groupCoding;
private String cargoArea;
private Long thePlatoon;
private Long theFloor;
private String directionType;
private Integer fullTrayCount;
private Integer emptyLocationCount;
private BigDecimal totalQuantity;
public String getGroupCoding() { return groupCoding; }
public void setGroupCoding(String groupCoding) { this.groupCoding = groupCoding; }
public String getCargoArea() { return cargoArea; }
public void setCargoArea(String cargoArea) { this.cargoArea = cargoArea; }
public Long getThePlatoon() { return thePlatoon; }
public void setThePlatoon(Long thePlatoon) { this.thePlatoon = thePlatoon; }
public Long getTheFloor() { return theFloor; }
public void setTheFloor(Long theFloor) { this.theFloor = theFloor; }
public String getDirectionType() { return directionType; }
public void setDirectionType(String directionType) { this.directionType = directionType; }
public Integer getFullTrayCount() { return fullTrayCount; }
public void setFullTrayCount(Integer fullTrayCount) { this.fullTrayCount = fullTrayCount; }
public Integer getEmptyLocationCount() { return emptyLocationCount; }
public void setEmptyLocationCount(Integer emptyLocationCount) { this.emptyLocationCount = emptyLocationCount; }
public BigDecimal getTotalQuantity() { return totalQuantity; }
public void setTotalQuantity(BigDecimal totalQuantity) { this.totalQuantity = totalQuantity; }
}

View File

@@ -0,0 +1,22 @@
package com.ruoyi.out.dense.domain;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
* 指定通道出库托盘预览。
*/
public class DenseLaneOutboundPreview
{
private List<DenseLanePosition> trays = new ArrayList<>();
private BigDecimal totalQuantity = BigDecimal.ZERO;
private String previewToken;
public List<DenseLanePosition> getTrays() { return trays; }
public void setTrays(List<DenseLanePosition> trays) { this.trays = trays; }
public BigDecimal getTotalQuantity() { return totalQuantity; }
public void setTotalQuantity(BigDecimal totalQuantity) { this.totalQuantity = totalQuantity; }
public String getPreviewToken() { return previewToken; }
public void setPreviewToken(String previewToken) { this.previewToken = previewToken; }
}

View File

@@ -0,0 +1,18 @@
package com.ruoyi.out.dense.domain;
/**
* 指定通道出库列表查询。
*/
public class DenseLaneOutboundQuery
{
private String warehouseId = "03";
private Long theFloor;
private String groupCoding;
public String getWarehouseId() { return warehouseId; }
public void setWarehouseId(String warehouseId) { this.warehouseId = warehouseId; }
public Long getTheFloor() { return theFloor; }
public void setTheFloor(Long theFloor) { this.theFloor = theFloor; }
public String getGroupCoding() { return groupCoding; }
public void setGroupCoding(String groupCoding) { this.groupCoding = groupCoding; }
}

View File

@@ -0,0 +1,27 @@
package com.ruoyi.out.dense.domain;
/**
* 指定通道整托出库预览/生成请求。
*/
public class DenseLaneOutboundRequest
{
private String warehouseId = "03";
private Long theFloor;
private String groupCoding;
private Integer trayCount;
private String exitId;
private String previewToken;
public String getWarehouseId() { return warehouseId; }
public void setWarehouseId(String warehouseId) { this.warehouseId = warehouseId; }
public Long getTheFloor() { return theFloor; }
public void setTheFloor(Long theFloor) { this.theFloor = theFloor; }
public String getGroupCoding() { return groupCoding; }
public void setGroupCoding(String groupCoding) { this.groupCoding = groupCoding; }
public Integer getTrayCount() { return trayCount; }
public void setTrayCount(Integer trayCount) { this.trayCount = trayCount; }
public String getExitId() { return exitId; }
public void setExitId(String exitId) { this.exitId = exitId; }
public String getPreviewToken() { return previewToken; }
public void setPreviewToken(String previewToken) { this.previewToken = previewToken; }
}

View File

@@ -0,0 +1,62 @@
package com.ruoyi.out.dense.domain;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import com.ruoyi.in.jobdetail.domain.MydoJobDetail;
/**
* 密集库通道货位及其几何信息。
*/
public class DenseLanePosition
{
private String cargoSpaceId;
private String cargoSpaceName;
private String warehouseId;
private String groupCoding;
private String cargoArea;
private Long thePlatoon;
private Long theColumn;
private Long theFloor;
private String status;
private String locked;
private String trayCode;
private String directionType;
private Long minColumn;
private Long maxColumn;
private BigDecimal stockQuantity;
private List<MydoJobDetail> stockDetails = new ArrayList<>();
public String getCargoSpaceId() { return cargoSpaceId; }
public void setCargoSpaceId(String cargoSpaceId) { this.cargoSpaceId = cargoSpaceId; }
public String getCargoSpaceName() { return cargoSpaceName; }
public void setCargoSpaceName(String cargoSpaceName) { this.cargoSpaceName = cargoSpaceName; }
public String getWarehouseId() { return warehouseId; }
public void setWarehouseId(String warehouseId) { this.warehouseId = warehouseId; }
public String getGroupCoding() { return groupCoding; }
public void setGroupCoding(String groupCoding) { this.groupCoding = groupCoding; }
public String getCargoArea() { return cargoArea; }
public void setCargoArea(String cargoArea) { this.cargoArea = cargoArea; }
public Long getThePlatoon() { return thePlatoon; }
public void setThePlatoon(Long thePlatoon) { this.thePlatoon = thePlatoon; }
public Long getTheColumn() { return theColumn; }
public void setTheColumn(Long theColumn) { this.theColumn = theColumn; }
public Long getTheFloor() { return theFloor; }
public void setTheFloor(Long theFloor) { this.theFloor = theFloor; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getLocked() { return locked; }
public void setLocked(String locked) { this.locked = locked; }
public String getTrayCode() { return trayCode; }
public void setTrayCode(String trayCode) { this.trayCode = trayCode; }
public String getDirectionType() { return directionType; }
public void setDirectionType(String directionType) { this.directionType = directionType; }
public Long getMinColumn() { return minColumn; }
public void setMinColumn(Long minColumn) { this.minColumn = minColumn; }
public Long getMaxColumn() { return maxColumn; }
public void setMaxColumn(Long maxColumn) { this.maxColumn = maxColumn; }
public BigDecimal getStockQuantity() { return stockQuantity; }
public void setStockQuantity(BigDecimal stockQuantity) { this.stockQuantity = stockQuantity; }
public List<MydoJobDetail> getStockDetails() { return stockDetails; }
public void setStockDetails(List<MydoJobDetail> stockDetails) { this.stockDetails = stockDetails; }
}

View File

@@ -0,0 +1,41 @@
package com.ruoyi.out.dense.domain;
import java.math.BigDecimal;
/**
* 03仓密集库通道整托库存汇总。
*/
public class DenseLaneSummary
{
private String groupCoding;
private String cargoArea;
private Long thePlatoon;
private Long theFloor;
private String directionType;
private Integer fullTrayCount;
private Integer availableTrayCount;
private BigDecimal totalQuantity;
private String outermostTrayCode;
private String outermostCargoSpaceId;
public String getGroupCoding() { return groupCoding; }
public void setGroupCoding(String groupCoding) { this.groupCoding = groupCoding; }
public String getCargoArea() { return cargoArea; }
public void setCargoArea(String cargoArea) { this.cargoArea = cargoArea; }
public Long getThePlatoon() { return thePlatoon; }
public void setThePlatoon(Long thePlatoon) { this.thePlatoon = thePlatoon; }
public Long getTheFloor() { return theFloor; }
public void setTheFloor(Long theFloor) { this.theFloor = theFloor; }
public String getDirectionType() { return directionType; }
public void setDirectionType(String directionType) { this.directionType = directionType; }
public Integer getFullTrayCount() { return fullTrayCount; }
public void setFullTrayCount(Integer fullTrayCount) { this.fullTrayCount = fullTrayCount; }
public Integer getAvailableTrayCount() { return availableTrayCount; }
public void setAvailableTrayCount(Integer availableTrayCount) { this.availableTrayCount = availableTrayCount; }
public BigDecimal getTotalQuantity() { return totalQuantity; }
public void setTotalQuantity(BigDecimal totalQuantity) { this.totalQuantity = totalQuantity; }
public String getOutermostTrayCode() { return outermostTrayCode; }
public void setOutermostTrayCode(String outermostTrayCode) { this.outermostTrayCode = outermostTrayCode; }
public String getOutermostCargoSpaceId() { return outermostCargoSpaceId; }
public void setOutermostCargoSpaceId(String outermostCargoSpaceId) { this.outermostCargoSpaceId = outermostCargoSpaceId; }
}

View File

@@ -0,0 +1,17 @@
package com.ruoyi.out.dense.domain;
import com.ruoyi.bas.cargospace.domain.BasCargoSpace;
/**
* 密集库移库源托盘与候选目标货位。
*/
public class DenseRelocationCandidate
{
private DenseLanePosition source;
private BasCargoSpace target;
public DenseLanePosition getSource() { return source; }
public void setSource(DenseLanePosition source) { this.source = source; }
public BasCargoSpace getTarget() { return target; }
public void setTarget(BasCargoSpace target) { this.target = target; }
}

View File

@@ -0,0 +1,36 @@
package com.ruoyi.out.dense.domain;
/**
* 密集库作业下发资格。
*/
public class DispatchEligibility
{
private final boolean eligible;
private final String reason;
private DispatchEligibility(boolean eligible, String reason)
{
this.eligible = eligible;
this.reason = reason;
}
public static DispatchEligibility allowed()
{
return new DispatchEligibility(true, null);
}
public static DispatchEligibility blocked(String reason)
{
return new DispatchEligibility(false, reason);
}
public boolean isEligible()
{
return eligible;
}
public String getReason()
{
return reason;
}
}

View File

@@ -0,0 +1,42 @@
package com.ruoyi.out.dense.domain;
/**
* 同层异通道移库目标查询条件。
*/
public class RelocationTargetQuery
{
private String warehouseId;
private Long theFloor;
private String sourceGroupCoding;
private String targetGroupCoding;
private String trayCode;
private String cargoSpaceType;
private Long height;
private String storageMode;
private String projectNo;
private String productId;
private String productName;
public String getWarehouseId() { return warehouseId; }
public void setWarehouseId(String warehouseId) { this.warehouseId = warehouseId; }
public Long getTheFloor() { return theFloor; }
public void setTheFloor(Long theFloor) { this.theFloor = theFloor; }
public String getSourceGroupCoding() { return sourceGroupCoding; }
public void setSourceGroupCoding(String sourceGroupCoding) { this.sourceGroupCoding = sourceGroupCoding; }
public String getTargetGroupCoding() { return targetGroupCoding; }
public void setTargetGroupCoding(String targetGroupCoding) { this.targetGroupCoding = targetGroupCoding; }
public String getTrayCode() { return trayCode; }
public void setTrayCode(String trayCode) { this.trayCode = trayCode; }
public String getCargoSpaceType() { return cargoSpaceType; }
public void setCargoSpaceType(String cargoSpaceType) { this.cargoSpaceType = cargoSpaceType; }
public Long getHeight() { return height; }
public void setHeight(Long height) { this.height = height; }
public String getStorageMode() { return storageMode; }
public void setStorageMode(String storageMode) { this.storageMode = storageMode; }
public String getProjectNo() { return projectNo; }
public void setProjectNo(String projectNo) { this.projectNo = projectNo; }
public String getProductId() { return productId; }
public void setProductId(String productId) { this.productId = productId; }
public String getProductName() { return productName; }
public void setProductName(String productName) { this.productName = productName; }
}

View File

@@ -0,0 +1,31 @@
package com.ruoyi.out.dense.mapper;
import java.util.List;
import com.ruoyi.bas.cargospace.domain.BasCargoSpace;
import com.ruoyi.out.dense.domain.DenseLanePosition;
import com.ruoyi.out.dense.domain.DenseLaneOption;
import com.ruoyi.out.dense.domain.DenseLaneOutboundQuery;
import com.ruoyi.out.dense.domain.DenseLaneSummary;
import com.ruoyi.out.dense.domain.RelocationTargetQuery;
import org.apache.ibatis.annotations.Param;
/**
* 密集库通道查询 Mapper。
*/
public interface DenseLaneMapper
{
Integer isCurrentOutermost(@Param("cargoSpaceId") String cargoSpaceId);
DenseLanePosition selectPosition(@Param("cargoSpaceId") String cargoSpaceId);
List<DenseLanePosition> selectOccupiedLanePositions(DenseLanePosition source);
List<DenseLanePosition> selectBlockersForUpdate(@Param("cargoSpaceId") String cargoSpaceId);
BasCargoSpace selectRelocationTargetForUpdate(RelocationTargetQuery query);
List<DenseLaneOption> selectLaneOptions(@Param("warehouseId") String warehouseId,
@Param("theFloor") Long theFloor);
List<DenseLaneSummary> selectLaneSummaries(DenseLaneOutboundQuery query);
}

View File

@@ -0,0 +1,19 @@
package com.ruoyi.out.dense.service;
import java.util.List;
import com.ruoyi.out.dense.domain.DenseLaneOutboundPreview;
import com.ruoyi.out.dense.domain.DenseLaneOutboundQuery;
import com.ruoyi.out.dense.domain.DenseLaneOutboundRequest;
import com.ruoyi.out.dense.domain.DenseLaneSummary;
/**
* 指定密集库通道整托出库服务。
*/
public interface IDenseLaneOutboundService
{
List<DenseLaneSummary> list(DenseLaneOutboundQuery query);
DenseLaneOutboundPreview preview(DenseLaneOutboundRequest request);
List<String> generate(DenseLaneOutboundRequest request);
}

View File

@@ -0,0 +1,22 @@
package com.ruoyi.out.dense.service;
import java.util.List;
import com.ruoyi.bas.cargospace.domain.BasCargoSpace;
import com.ruoyi.in.job.domain.MydoJob;
import com.ruoyi.out.dense.domain.DenseLanePosition;
import com.ruoyi.out.dense.domain.DispatchEligibility;
import com.ruoyi.out.dense.domain.RelocationTargetQuery;
/**
* 密集库通道服务。
*/
public interface IDenseLaneService
{
DispatchEligibility checkDispatchEligibility(MydoJob job);
List<DenseLanePosition> orderOutermostFirst(List<DenseLanePosition> positions);
List<DenseLanePosition> selectBlockersForUpdate(String cargoSpaceId);
BasCargoSpace selectRelocationTargetForUpdate(RelocationTargetQuery query);
}

View File

@@ -0,0 +1,318 @@
package com.ruoyi.out.dense.service.impl;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.stream.Collectors;
import com.ruoyi.bas.cargospace.mapper.BasCargoSpaceMapper;
import com.ruoyi.bas.id.job.service.JobIdService;
import com.ruoyi.bas.id.taskno.service.TaskNoService;
import com.ruoyi.bas.stationpoint.domain.BasStationPoint;
import com.ruoyi.bas.stationpoint.service.IBasStationPointService;
import com.ruoyi.bas.stock.mapper.BasStockMapper;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.in.job.domain.MydoJob;
import com.ruoyi.in.job.service.IMydoJobService;
import com.ruoyi.in.jobdetail.domain.MydoJobDetail;
import com.ruoyi.in.jobdetail.service.IMydoJobDetailService;
import com.ruoyi.out.dense.domain.DenseLaneOutboundPreview;
import com.ruoyi.out.dense.domain.DenseLaneOutboundQuery;
import com.ruoyi.out.dense.domain.DenseLaneOutboundRequest;
import com.ruoyi.out.dense.domain.DenseLanePosition;
import com.ruoyi.out.dense.domain.DenseLaneSummary;
import com.ruoyi.out.dense.mapper.DenseLaneMapper;
import com.ruoyi.out.dense.service.IDenseLaneOutboundService;
import com.ruoyi.out.dense.service.IDenseLaneService;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
import com.ruoyi.out.dispatch.service.IOutboundDispatchService;
import com.ruoyi.out.location.mapper.LocationOutboundMapper;
import com.ruoyi.out.relocation.domain.RelocationGenerateRequest;
import com.ruoyi.out.relocation.mapper.RelocationJobMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/**
* 指定密集库通道整托出库服务实现。
*/
@Service
public class DenseLaneOutboundServiceImpl implements IDenseLaneOutboundService
{
private final DenseLaneMapper denseLaneMapper;
private final IDenseLaneService denseLaneService;
private final RelocationJobMapper relocationJobMapper;
private final IBasStationPointService stationPointService;
private final IMydoJobService mydoJobService;
private final IMydoJobDetailService mydoJobDetailService;
private final LocationOutboundMapper locationOutboundMapper;
private final BasStockMapper basStockMapper;
private final BasCargoSpaceMapper cargoSpaceMapper;
private final JobIdService jobIdService;
private final TaskNoService taskNoService;
private final IOutboundDispatchService dispatchService;
public DenseLaneOutboundServiceImpl(DenseLaneMapper denseLaneMapper,
IDenseLaneService denseLaneService,
RelocationJobMapper relocationJobMapper,
IBasStationPointService stationPointService,
IMydoJobService mydoJobService,
IMydoJobDetailService mydoJobDetailService,
LocationOutboundMapper locationOutboundMapper,
BasStockMapper basStockMapper,
BasCargoSpaceMapper cargoSpaceMapper,
JobIdService jobIdService,
TaskNoService taskNoService,
IOutboundDispatchService dispatchService)
{
this.denseLaneMapper = denseLaneMapper;
this.denseLaneService = denseLaneService;
this.relocationJobMapper = relocationJobMapper;
this.stationPointService = stationPointService;
this.mydoJobService = mydoJobService;
this.mydoJobDetailService = mydoJobDetailService;
this.locationOutboundMapper = locationOutboundMapper;
this.basStockMapper = basStockMapper;
this.cargoSpaceMapper = cargoSpaceMapper;
this.jobIdService = jobIdService;
this.taskNoService = taskNoService;
this.dispatchService = dispatchService;
}
@Override
public List<DenseLaneSummary> list(DenseLaneOutboundQuery query)
{
if (query == null) {
query = new DenseLaneOutboundQuery();
}
if (!"03".equals(query.getWarehouseId())) {
throw new RuntimeException("指定通道出库仅支持03仓密集库");
}
List<DenseLaneSummary> summaries = denseLaneMapper.selectLaneSummaries(query);
return summaries == null ? new ArrayList<>() : summaries;
}
@Override
public DenseLaneOutboundPreview preview(DenseLaneOutboundRequest request)
{
validateRequest(request, false);
return buildPreview(request);
}
@Override
@Transactional(rollbackFor = Exception.class)
public List<String> generate(DenseLaneOutboundRequest request)
{
validateRequest(request, true);
DenseLaneOutboundPreview current = buildPreview(request);
if (!request.getPreviewToken().equals(current.getPreviewToken())) {
throw new RuntimeException("预览已失效,请刷新后重新确认");
}
BasStationPoint exit = validateExit(request.getExitId(), current.getTrays());
List<String> jobIds = new ArrayList<>();
for (DenseLanePosition position : current.getTrays()) {
List<MydoJobDetail> stockDetails = relocationJobMapper.selectStockDetailsForUpdate(
position.getCargoSpaceId(), position.getTrayCode());
if (stockDetails == null || stockDetails.isEmpty()) {
throw new RuntimeException("托盘" + position.getTrayCode() + "没有可出库库存明细");
}
String jobId = jobIdService.getId("CK");
if (!StringUtils.hasText(jobId)) {
throw new RuntimeException("指定通道出库作业ID生成失败");
}
MydoJob job = buildJob(jobId, taskNoService.getTaskNo("20"), position, exit);
if (mydoJobService.insertMydoJob(job) != 1) {
throw new RuntimeException("指定通道出库作业生成失败");
}
int index = 1;
for (MydoJobDetail template : stockDetails) {
MydoJobDetail detail = buildDetail(template, jobId, index++);
if (mydoJobDetailService.insertMydoJobDetail(detail) != 1) {
throw new RuntimeException("指定通道出库作业明细生成失败");
}
HashMap<String, Object> reserve = new HashMap<>();
reserve.put("stockDetailId", template.getStockinDetailId());
reserve.put("stockId", template.getStockId());
reserve.put("jobDetailId", detail.getJobDetailId());
reserve.put("quantity", detail.getOutQuantity());
if (locationOutboundMapper.reserveStockDetailForOutbound(reserve) != 1) {
throw new RuntimeException("托盘库存状态已变化,请刷新后重试");
}
}
stockDetails.stream().map(MydoJobDetail::getStockId).filter(StringUtils::hasText)
.distinct().forEach(basStockMapper::refreshBasStockSummary);
if (cargoSpaceMapper.markOutboundAssignedByCargoSpaceId(position.getCargoSpaceId()) != 1) {
throw new RuntimeException("端头货位状态已变化,请刷新后重试");
}
jobIds.add(jobId);
}
afterCommit(() -> jobIds.forEach(id -> dispatchService.dispatch(id, DispatchTrigger.INITIAL)));
return jobIds;
}
private DenseLaneOutboundPreview buildPreview(DenseLaneOutboundRequest request)
{
RelocationGenerateRequest sourceQuery = new RelocationGenerateRequest();
sourceQuery.setWarehouseId(request.getWarehouseId());
sourceQuery.setTheFloor(request.getTheFloor());
sourceQuery.setSourceGroupCoding(request.getGroupCoding());
sourceQuery.setTrayCount(request.getTrayCount());
List<DenseLanePosition> available = relocationJobMapper.selectRelocationSourcesForUpdate(sourceQuery);
if (available == null || available.size() < request.getTrayCount()) {
throw new RuntimeException("当前通道可出库托盘数量不足");
}
List<DenseLanePosition> selected = denseLaneService.orderOutermostFirst(available)
.stream().limit(request.getTrayCount()).collect(Collectors.toList());
selected.forEach(position -> {
List<MydoJobDetail> details = relocationJobMapper.selectStockDetailsForUpdate(
position.getCargoSpaceId(), position.getTrayCode());
position.setStockDetails(details == null ? new ArrayList<>() : details);
});
BigDecimal total = selected.stream()
.flatMap(position -> position.getStockDetails().stream())
.map(MydoJobDetail::getStockQuantity)
.filter(quantity -> quantity != null)
.reduce(BigDecimal.ZERO, BigDecimal::add);
DenseLaneOutboundPreview preview = new DenseLaneOutboundPreview();
preview.setTrays(selected);
preview.setTotalQuantity(total);
preview.setPreviewToken(previewToken(request, selected));
return preview;
}
private void validateRequest(DenseLaneOutboundRequest request, boolean requireToken)
{
if (request == null || !"03".equals(request.getWarehouseId())) {
throw new RuntimeException("指定通道出库仅支持03仓密集库");
}
if (request.getTheFloor() == null || !StringUtils.hasText(request.getGroupCoding())) {
throw new RuntimeException("请选择密集库层和通道");
}
if (request.getTrayCount() == null || request.getTrayCount() <= 0) {
throw new RuntimeException("出库托盘数量必须大于0");
}
if (requireToken && !StringUtils.hasText(request.getPreviewToken())) {
throw new RuntimeException("请先预览要出库的端头托盘");
}
}
private BasStationPoint validateExit(String exitId, List<DenseLanePosition> positions)
{
if (!StringUtils.hasText(exitId)) {
throw new RuntimeException("请选择出库口");
}
BasStationPoint point = stationPointService.selectBasStationPointByPointId(exitId);
if (point == null || !"03".equals(point.getWarehouseId())) {
throw new RuntimeException("出库口不存在或不属于03仓");
}
if ((!"2".equals(point.getPointType()) && !"3".equals(point.getPointType()))
|| !"0".equals(point.getStatus())
|| (!StringUtils.isEmpty(point.getPointStatus()) && !"0".equals(point.getPointStatus()))) {
throw new RuntimeException("当前出库口不可用");
}
for (DenseLanePosition position : positions) {
String trayType = trayType(position.getTrayCode());
if (StringUtils.hasText(trayType) && StringUtils.hasText(point.getTrayType())
&& !supportsTrayType(point.getTrayType(), trayType)) {
throw new RuntimeException("出库口不支持托盘类型" + trayType);
}
}
return point;
}
private MydoJob buildJob(String jobId, String taskNo, DenseLanePosition position,
BasStationPoint exit)
{
MydoJob job = new MydoJob();
job.setJobId(jobId);
job.setTaskNo(StringUtils.hasText(taskNo) ? taskNo : jobId);
job.setInOrOut(20L);
job.setStatus("1");
job.setExecutionPhase("0");
job.setTrayCode(position.getTrayCode());
job.setWarehouseId("03");
job.setCargoSpaceId(position.getCargoSpaceId());
job.setCargoSpaceName(position.getCargoSpaceName());
job.setExitId(exit.getPointId());
job.setLineType("1");
job.setPriority(10L);
job.setCreateTime(DateUtils.getNowDate());
return job;
}
private MydoJobDetail buildDetail(MydoJobDetail source, String jobId, int index)
{
MydoJobDetail detail = new MydoJobDetail();
detail.setJobDetailId(jobId + "-" + index);
detail.setJobId(jobId);
detail.setStockId(source.getStockId());
detail.setLotNo(source.getLotNo());
detail.setInDate(source.getInDate());
detail.setProductId(source.getProductId());
detail.setProductName(source.getProductName());
detail.setStockQuantity(source.getStockQuantity());
detail.setOutQuantity(source.getStockQuantity());
detail.setStockinId(source.getStockinId());
detail.setStockinDetailId(source.getStockinDetailId());
detail.setSupplierId(source.getSupplierId());
detail.setSupplierName(source.getSupplierName());
detail.setProjectNo(source.getProjectNo());
return detail;
}
private String previewToken(DenseLaneOutboundRequest request, List<DenseLanePosition> selected)
{
String payload = request.getWarehouseId() + "|" + request.getTheFloor() + "|"
+ request.getGroupCoding() + "|" + request.getTrayCount() + "|"
+ selected.stream().map(item -> item.getCargoSpaceId() + ":" + item.getTrayCode()
+ "[" + item.getStockDetails().stream()
.map(detail -> String.valueOf(detail.getStockinDetailId()) + ":"
+ String.valueOf(detail.getStockId()) + ":"
+ String.valueOf(detail.getStockQuantity()))
.sorted()
.collect(Collectors.joining(";")) + "]")
.collect(Collectors.joining(","));
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(payload.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new RuntimeException("生成通道出库预览签名失败", e);
}
}
private String trayType(String trayCode)
{
return StringUtils.hasText(trayCode) ? trayCode.trim().substring(0, 1).toUpperCase() : null;
}
private boolean supportsTrayType(String supported, String trayType)
{
for (String item : supported.split(",")) {
if (trayType.equalsIgnoreCase(item.trim())) {
return true;
}
}
return false;
}
private void afterCommit(Runnable action)
{
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
action.run();
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization()
{
@Override
public void afterCommit()
{
action.run();
}
});
}
}

View File

@@ -0,0 +1,104 @@
package com.ruoyi.out.dense.service.impl;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Collections;
import java.util.List;
import com.ruoyi.bas.cargospace.domain.BasCargoSpace;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.in.job.domain.MydoJob;
import com.ruoyi.out.dense.domain.DenseLanePosition;
import com.ruoyi.out.dense.domain.DispatchEligibility;
import com.ruoyi.out.dense.domain.RelocationTargetQuery;
import com.ruoyi.out.dense.mapper.DenseLaneMapper;
import com.ruoyi.out.dense.service.IDenseLaneService;
import org.springframework.stereotype.Service;
/**
* 密集库通道服务实现。
*/
@Service
public class DenseLaneServiceImpl implements IDenseLaneService
{
private final DenseLaneMapper denseLaneMapper;
public DenseLaneServiceImpl(DenseLaneMapper denseLaneMapper)
{
this.denseLaneMapper = denseLaneMapper;
}
@Override
public DispatchEligibility checkDispatchEligibility(MydoJob job)
{
if (job == null) {
return DispatchEligibility.blocked("出库作业不存在");
}
if (!"03".equals(job.getWarehouseId())) {
return DispatchEligibility.allowed();
}
if (!StringUtils.hasText(job.getCargoSpaceId())) {
return DispatchEligibility.blocked("03仓出库作业未分配源货位");
}
DenseLanePosition source = denseLaneMapper.selectPosition(job.getCargoSpaceId());
if (source == null) {
return DispatchEligibility.blocked("出库作业源货位不存在");
}
if (!StringUtils.hasText(source.getGroupCoding())) {
return DispatchEligibility.allowed();
}
List<DenseLanePosition> ordered = orderOutermostFirst(
denseLaneMapper.selectOccupiedLanePositions(source));
if (ordered.isEmpty() || job.getCargoSpaceId().equals(ordered.get(0).getCargoSpaceId())) {
return DispatchEligibility.allowed();
}
return DispatchEligibility.blocked("当前货位不是密集库通道最外侧货位");
}
@Override
public List<DenseLanePosition> orderOutermostFirst(List<DenseLanePosition> positions)
{
if (positions == null || positions.isEmpty()) {
return Collections.emptyList();
}
List<DenseLanePosition> ordered = new ArrayList<>(positions);
String direction = StringUtils.defaultIfEmpty(ordered.get(0).getDirectionType(), "1");
Comparator<DenseLanePosition> comparator;
if ("2".equals(direction)) {
comparator = Comparator.comparing(DenseLanePosition::getTheColumn,
Comparator.nullsLast(Long::compareTo));
} else if ("3".equals(direction)) {
comparator = Comparator
.comparingLong(this::distanceToNearestBoundary)
.thenComparing(DenseLanePosition::getTheColumn,
Comparator.nullsLast(Long::compareTo));
} else {
comparator = Comparator.comparing(DenseLanePosition::getTheColumn,
Comparator.nullsLast(Long::compareTo)).reversed();
}
ordered.sort(comparator);
return ordered;
}
@Override
public List<DenseLanePosition> selectBlockersForUpdate(String cargoSpaceId)
{
List<DenseLanePosition> blockers = denseLaneMapper.selectBlockersForUpdate(cargoSpaceId);
return blockers == null ? Collections.emptyList() : blockers;
}
@Override
public BasCargoSpace selectRelocationTargetForUpdate(RelocationTargetQuery query)
{
return denseLaneMapper.selectRelocationTargetForUpdate(query);
}
private long distanceToNearestBoundary(DenseLanePosition position)
{
if (position.getTheColumn() == null || position.getMinColumn() == null
|| position.getMaxColumn() == null) {
return Long.MAX_VALUE;
}
return Math.min(position.getTheColumn() - position.getMinColumn(),
position.getMaxColumn() - position.getTheColumn());
}
}

View File

@@ -0,0 +1,11 @@
package com.ruoyi.out.dispatch.domain;
/**
* WCS 下发触发方式。
*/
public enum DispatchTrigger
{
INITIAL,
AUTO_RETRY,
MANUAL
}

View File

@@ -0,0 +1,141 @@
package com.ruoyi.out.dispatch.domain;
import java.io.Serializable;
import java.util.Date;
/**
* WCS 作业下发记录。
*/
public class WcsJobDispatch implements Serializable
{
private static final long serialVersionUID = 1L;
public static final String PENDING = "PENDING";
public static final String SENDING = "SENDING";
public static final String BLOCKED = "BLOCKED";
public static final String RETRY_WAIT = "RETRY_WAIT";
public static final String SUCCESS = "SUCCESS";
public static final String EXHAUSTED = "EXHAUSTED";
private String jobId;
private String dispatchStatus;
private Integer autoRetryCount;
private Integer manualRetryCount;
private String lastError;
private Date lastAttemptTime;
private Date nextRetryTime;
private Date successTime;
private Integer versionNo;
private Date createTime;
private Date updateTime;
public String getJobId()
{
return jobId;
}
public void setJobId(String jobId)
{
this.jobId = jobId;
}
public String getDispatchStatus()
{
return dispatchStatus;
}
public void setDispatchStatus(String dispatchStatus)
{
this.dispatchStatus = dispatchStatus;
}
public Integer getAutoRetryCount()
{
return autoRetryCount;
}
public void setAutoRetryCount(Integer autoRetryCount)
{
this.autoRetryCount = autoRetryCount;
}
public Integer getManualRetryCount()
{
return manualRetryCount;
}
public void setManualRetryCount(Integer manualRetryCount)
{
this.manualRetryCount = manualRetryCount;
}
public String getLastError()
{
return lastError;
}
public void setLastError(String lastError)
{
this.lastError = lastError;
}
public Date getLastAttemptTime()
{
return lastAttemptTime;
}
public void setLastAttemptTime(Date lastAttemptTime)
{
this.lastAttemptTime = lastAttemptTime;
}
public Date getNextRetryTime()
{
return nextRetryTime;
}
public void setNextRetryTime(Date nextRetryTime)
{
this.nextRetryTime = nextRetryTime;
}
public Date getSuccessTime()
{
return successTime;
}
public void setSuccessTime(Date successTime)
{
this.successTime = successTime;
}
public Integer getVersionNo()
{
return versionNo;
}
public void setVersionNo(Integer versionNo)
{
this.versionNo = versionNo;
}
public Date getCreateTime()
{
return createTime;
}
public void setCreateTime(Date createTime)
{
this.createTime = createTime;
}
public Date getUpdateTime()
{
return updateTime;
}
public void setUpdateTime(Date updateTime)
{
this.updateTime = updateTime;
}
}

View File

@@ -0,0 +1,29 @@
package com.ruoyi.out.dispatch.mapper;
import java.util.Date;
import java.util.List;
import java.util.Map;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.out.dispatch.domain.WcsJobDispatch;
/**
* WCS 作业下发记录 Mapper。
*/
public interface WcsJobDispatchMapper
{
int insertIfAbsent(String jobId);
WcsJobDispatch selectByJobId(String jobId);
List<WcsJobDispatch> selectRetryCandidates(@Param("now") Date now, @Param("limit") int limit);
int claimForSend(Map<String, Object> params);
int markBlocked(Map<String, Object> params);
int markRetryWait(Map<String, Object> params);
int markSuccess(Map<String, Object> params);
int markExhausted(Map<String, Object> params);
}

View File

@@ -0,0 +1,14 @@
package com.ruoyi.out.dispatch.service;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
/**
* 出库作业 WCS 下发服务。
*/
public interface IOutboundDispatchService
{
AjaxResult dispatch(String jobId, DispatchTrigger trigger);
int retryFailedJobs();
}

View File

@@ -0,0 +1,239 @@
package com.ruoyi.out.dispatch.service.impl;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.ruoyi.api.client.WcsClient;
import com.ruoyi.api.domain.ApiResponse;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.text.Convert;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.in.job.domain.MydoJob;
import com.ruoyi.in.job.mapper.MydoJobMapper;
import com.ruoyi.out.dense.domain.DispatchEligibility;
import com.ruoyi.out.dense.service.IDenseLaneService;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
import com.ruoyi.out.dispatch.domain.WcsJobDispatch;
import com.ruoyi.out.dispatch.mapper.WcsJobDispatchMapper;
import com.ruoyi.out.dispatch.service.IOutboundDispatchService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/**
* 出库作业 WCS 下发服务实现。
*/
@Service
public class OutboundDispatchServiceImpl implements IOutboundDispatchService
{
private static final Logger log = LoggerFactory.getLogger(OutboundDispatchServiceImpl.class);
private static final int MAX_AUTO_RETRY_COUNT = 5;
private static final int RETRY_BATCH_SIZE = 100;
private static final long RETRY_DELAY_MILLIS = 60_000L;
private final MydoJobMapper jobMapper;
private final WcsJobDispatchMapper dispatchMapper;
private final IDenseLaneService denseLaneService;
private final WcsClient wcsClient;
public OutboundDispatchServiceImpl(MydoJobMapper jobMapper,
WcsJobDispatchMapper dispatchMapper,
IDenseLaneService denseLaneService,
WcsClient wcsClient)
{
this.jobMapper = jobMapper;
this.dispatchMapper = dispatchMapper;
this.denseLaneService = denseLaneService;
this.wcsClient = wcsClient;
}
@Override
public AjaxResult dispatch(String jobId, DispatchTrigger trigger)
{
if (!StringUtils.hasText(jobId)) {
return AjaxResult.error("作业ID不能为空");
}
if (trigger == null) {
return AjaxResult.error("WCS下发触发方式不能为空");
}
MydoJob job = jobMapper.selectMydoJobByJobId(jobId.trim());
AjaxResult validation = validateJob(job);
if (validation != null) {
return validation;
}
dispatchMapper.insertIfAbsent(job.getJobId());
WcsJobDispatch dispatch = dispatchMapper.selectByJobId(job.getJobId());
if (dispatch == null) {
return AjaxResult.error("WCS下发记录创建失败");
}
if (isActiveSending(dispatch)) {
return AjaxResult.success("作业正在下发WCS,无需重复处理");
}
if (trigger == DispatchTrigger.AUTO_RETRY
&& retryCount(dispatch) >= MAX_AUTO_RETRY_COUNT) {
if (!WcsJobDispatch.EXHAUSTED.equals(dispatch.getDispatchStatus())) {
Map<String, Object> exhaustedParams = markParams(
job.getJobId(), dispatch, "WCS自动重试次数已达上限", new Date());
exhaustedParams.put("autoRetryCount", retryCount(dispatch));
dispatchMapper.markExhausted(exhaustedParams);
}
return AjaxResult.success("WCS自动重试次数已达上限,等待人工下发");
}
DispatchEligibility eligibility = denseLaneService.checkDispatchEligibility(job);
if (eligibility == null || !eligibility.isEligible()) {
String reason = eligibility == null ? "密集库下发资格校验无返回" : eligibility.getReason();
dispatchMapper.markBlocked(markParams(job.getJobId(), dispatch, reason, new Date()));
return AjaxResult.success("作业暂未下发WCS:" + reason);
}
DispatchTrigger attemptTrigger = resolveAttemptTrigger(trigger, dispatch);
Date attemptTime = new Date();
Map<String, Object> claimParams = new HashMap<>();
claimParams.put("jobId", job.getJobId());
claimParams.put("trigger", attemptTrigger.name());
claimParams.put("attemptTime", attemptTime);
claimParams.put("versionNo", version(dispatch));
if (dispatchMapper.claimForSend(claimParams) != 1) {
return AjaxResult.success("作业正在下发或状态已变化,无需重复处理");
}
int claimedVersion = version(dispatch) + 1;
ApiResponse<String> response;
try {
response = sendByType(job);
} catch (Exception e) {
log.error("WCS出库作业下发异常,jobId={}", job.getJobId(), e);
return handleFailure(job, dispatch, claimedVersion, attemptTrigger, e.getMessage());
}
if (response == null || !response.isSuccess()) {
String errorMessage = response == null ? "WCS接口无返回" : response.getMessage();
return handleFailure(job, dispatch, claimedVersion, attemptTrigger, errorMessage);
}
MydoJob updateJob = new MydoJob();
updateJob.setJobId(job.getJobId());
updateJob.setStatus("3");
updateJob.setExecutionPhase("1");
updateJob.setStartTime(DateUtils.getTime());
if (jobMapper.updateMydoJob(updateJob) != 1) {
return handleFailure(job, dispatch, claimedVersion, attemptTrigger, "WCS已接收,但出库作业状态更新失败");
}
Map<String, Object> successParams = new HashMap<>();
successParams.put("jobId", job.getJobId());
successParams.put("successTime", new Date());
successParams.put("versionNo", claimedVersion);
dispatchMapper.markSuccess(successParams);
return AjaxResult.success("WCS出库作业下发成功:" + Convert.toStr(response.getData()));
}
@Override
public int retryFailedJobs()
{
List<WcsJobDispatch> candidates = dispatchMapper.selectRetryCandidates(new Date(), RETRY_BATCH_SIZE);
int processed = 0;
for (WcsJobDispatch candidate : candidates) {
try {
dispatch(candidate.getJobId(), DispatchTrigger.AUTO_RETRY);
} catch (Exception e) {
log.error("WCS出库作业自动补偿异常,jobId={}", candidate.getJobId(), e);
}
processed++;
}
return processed;
}
private AjaxResult validateJob(MydoJob job)
{
if (job == null) {
return AjaxResult.error("出库作业不存在");
}
if (job.getInOrOut() == null || job.getInOrOut() < 20) {
return AjaxResult.error("请选择出库作业");
}
if (!"1".equals(job.getStatus())) {
if ("2".equals(job.getStatus()) || "3".equals(job.getStatus())) {
return AjaxResult.success("出库作业已下发,无需重复推送");
}
return AjaxResult.error("当前作业状态不允许下发WCS");
}
return null;
}
private ApiResponse<String> sendByType(MydoJob job)
{
if (Long.valueOf(20L).equals(job.getInOrOut())) {
return wcsClient.sendLocationOutboundTask(job);
}
if (Long.valueOf(23L).equals(job.getInOrOut())) {
return wcsClient.sendRelocationTask(job);
}
return wcsClient.sendRetrieveTask(job);
}
private AjaxResult handleFailure(MydoJob job, WcsJobDispatch dispatch, int claimedVersion,
DispatchTrigger trigger, String errorMessage)
{
int autoRetryCount = retryCount(dispatch)
+ (trigger == DispatchTrigger.AUTO_RETRY ? 1 : 0);
String reason = StringUtils.hasText(errorMessage) ? errorMessage : "WCS下发失败";
Map<String, Object> params = markParams(job.getJobId(), dispatch, reason, new Date());
params.put("autoRetryCount", autoRetryCount);
params.put("versionNo", claimedVersion);
if (trigger == DispatchTrigger.MANUAL || autoRetryCount >= MAX_AUTO_RETRY_COUNT) {
dispatchMapper.markExhausted(params);
if (trigger == DispatchTrigger.MANUAL) {
return AjaxResult.error("WCS手动下发失败:" + reason);
}
return AjaxResult.success("WCS自动重试已达5次,等待人工下发:" + reason);
}
params.put("nextRetryTime", new Date(System.currentTimeMillis() + RETRY_DELAY_MILLIS));
dispatchMapper.markRetryWait(params);
return AjaxResult.success("出库作业已生成,WCS下发失败,已进入补偿队列:" + reason);
}
private Map<String, Object> markParams(String jobId, WcsJobDispatch dispatch,
String lastError, Date updateTime)
{
Map<String, Object> params = new HashMap<>();
params.put("jobId", jobId);
params.put("lastError", lastError);
params.put("updateTime", updateTime);
params.put("versionNo", version(dispatch));
params.put("autoRetryCount", retryCount(dispatch));
params.put("nextRetryTime", new Date(updateTime.getTime() + RETRY_DELAY_MILLIS));
return params;
}
private int retryCount(WcsJobDispatch dispatch)
{
return dispatch.getAutoRetryCount() == null ? 0 : dispatch.getAutoRetryCount();
}
private DispatchTrigger resolveAttemptTrigger(DispatchTrigger requestedTrigger, WcsJobDispatch dispatch)
{
if (requestedTrigger == DispatchTrigger.AUTO_RETRY && dispatch.getLastAttemptTime() == null) {
return DispatchTrigger.INITIAL;
}
return requestedTrigger;
}
private boolean isActiveSending(WcsJobDispatch dispatch)
{
return WcsJobDispatch.SENDING.equals(dispatch.getDispatchStatus())
&& dispatch.getLastAttemptTime() != null
&& dispatch.getLastAttemptTime().getTime() > System.currentTimeMillis() - 5 * 60_000L;
}
private int version(WcsJobDispatch dispatch)
{
return dispatch.getVersionNo() == null ? 0 : dispatch.getVersionNo();
}
}

View File

@@ -0,0 +1,23 @@
package com.ruoyi.out.dispatch.task;
import com.ruoyi.out.dispatch.service.IOutboundDispatchService;
import org.springframework.stereotype.Component;
/**
* WCS 出库作业失败补偿任务。
*/
@Component("wcsOutboundRetryTask")
public class WcsOutboundRetryTask
{
private final IOutboundDispatchService outboundDispatchService;
public WcsOutboundRetryTask(IOutboundDispatchService outboundDispatchService)
{
this.outboundDispatchService = outboundDispatchService;
}
public int retryFailedJobs()
{
return outboundDispatchService.retryFailedJobs();
}
}

View File

@@ -7,6 +7,8 @@ import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.in.job.domain.MydoJob;
import com.ruoyi.in.job.service.IMydoJobService;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
import com.ruoyi.out.dispatch.service.IOutboundDispatchService;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired;
@@ -37,6 +39,9 @@ public class OutMydoJobController extends BaseController
@Autowired
private IMydoJobService mydoJobService;
@Autowired
private IOutboundDispatchService outboundDispatchService;
/**
* 查询作业信息列表
*/
@@ -147,11 +152,11 @@ public class OutMydoJobController extends BaseController
/**
* 推送出库作业到 WCS
*/
@PreAuthorize("@ss.hasPermi('out:job:edit')")
@PreAuthorize("@ss.hasPermi('out:job:pushWcs')")
@Log(title = "作业信息-WCS推送", businessType = BusinessType.UPDATE)
@PutMapping("/pushWcs/{jobId}")
public AjaxResult pushWcs(@PathVariable("jobId") String jobId)
{
return mydoJobService.pushOutJobToWcs(jobId);
return outboundDispatchService.dispatch(jobId, DispatchTrigger.MANUAL);
}
}

View File

@@ -28,9 +28,14 @@ import com.ruoyi.out.location.domain.LocationOutboundGenerateRequest;
import com.ruoyi.out.location.domain.LocationOutboundStock;
import com.ruoyi.out.location.mapper.LocationOutboundMapper;
import com.ruoyi.out.location.service.ILocationOutboundService;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
import com.ruoyi.out.dispatch.service.IOutboundDispatchService;
import com.ruoyi.out.relocation.service.IRelocationJobService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/**
* 指定货位出库服务实现。
@@ -62,6 +67,12 @@ public class LocationOutboundServiceImpl implements ILocationOutboundService
@Autowired
private TaskNoService taskNoService;
@Autowired
private IRelocationJobService relocationJobService;
@Autowired
private IOutboundDispatchService dispatchService;
@Override
@WarehouseScope(warehouseField = "bcs.warehouse_id")
public List<LocationOutboundStock> selectLocationOutboundStockList(LocationOutboundStock query)
@@ -150,11 +161,12 @@ public class LocationOutboundServiceImpl implements ILocationOutboundService
basCargoSpaceMapper.markOutboundAssignedByStockId(stock.getStockId());
}
AjaxResult pushResult = mydoJobService.pushOutJobToWcs(jobId);
if (pushResult == null || pushResult.isError()) {
throw new RuntimeException(pushResult == null ? "WCS出库作业下发失败" : Convert.toStr(pushResult.get(AjaxResult.MSG_TAG)));
}
return "指定货位出库作业生成成功:" + jobId + ";" + Convert.toStr(pushResult.get(AjaxResult.MSG_TAG));
List<String> relocationIds = relocationJobService.createBlockerRelocations(jobId);
AjaxResult pushResult = dispatchAfterCommit(jobId);
String relocationMessage = relocationIds == null || relocationIds.isEmpty()
? "" : ";已生成" + relocationIds.size() + "个遮挡移库作业";
return "指定货位出库作业生成成功:" + jobId + relocationMessage
+ ";" + Convert.toStr(pushResult.get(AjaxResult.MSG_TAG));
}
private void checkRequest(LocationOutboundGenerateRequest request)
@@ -308,4 +320,21 @@ public class LocationOutboundServiceImpl implements ILocationOutboundService
// External callbacks and focused unit tests can run without login context.
}
}
private AjaxResult dispatchAfterCommit(String jobId)
{
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
AjaxResult result = dispatchService.dispatch(jobId, DispatchTrigger.INITIAL);
return result == null ? AjaxResult.error("WCS统一下发服务无返回") : result;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization()
{
@Override
public void afterCommit()
{
dispatchService.dispatch(jobId, DispatchTrigger.INITIAL);
}
});
return AjaxResult.success("作业已生成,事务提交后下发WCS");
}
}

View File

@@ -0,0 +1,40 @@
package com.ruoyi.out.relocation.controller;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.out.relocation.domain.RelocationGenerateRequest;
import com.ruoyi.out.relocation.service.IRelocationJobService;
import org.springframework.security.access.prepost.PreAuthorize;
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;
/**
* 密集库移库作业接口。
*/
@RestController
@RequestMapping("/out/relocation")
public class RelocationJobController extends BaseController
{
private final IRelocationJobService relocationJobService;
public RelocationJobController(IRelocationJobService relocationJobService)
{
this.relocationJobService = relocationJobService;
}
@PreAuthorize("@ss.hasPermi('out:relocation:preview')")
@PostMapping("/preview")
public AjaxResult preview(@RequestBody RelocationGenerateRequest request)
{
return success(relocationJobService.preview(request));
}
@PreAuthorize("@ss.hasPermi('out:relocation:generate')")
@PostMapping("/generate")
public AjaxResult generate(@RequestBody RelocationGenerateRequest request)
{
return success(relocationJobService.generate(request));
}
}

View File

@@ -0,0 +1,24 @@
package com.ruoyi.out.relocation.domain;
/**
* 密集库指定移库请求。
*/
public class RelocationGenerateRequest
{
private String warehouseId;
private Long theFloor;
private String sourceGroupCoding;
private String targetGroupCoding;
private Integer trayCount;
public String getWarehouseId() { return warehouseId; }
public void setWarehouseId(String warehouseId) { this.warehouseId = warehouseId; }
public Long getTheFloor() { return theFloor; }
public void setTheFloor(Long theFloor) { this.theFloor = theFloor; }
public String getSourceGroupCoding() { return sourceGroupCoding; }
public void setSourceGroupCoding(String sourceGroupCoding) { this.sourceGroupCoding = sourceGroupCoding; }
public String getTargetGroupCoding() { return targetGroupCoding; }
public void setTargetGroupCoding(String targetGroupCoding) { this.targetGroupCoding = targetGroupCoding; }
public Integer getTrayCount() { return trayCount; }
public void setTrayCount(Integer trayCount) { this.trayCount = trayCount; }
}

View File

@@ -0,0 +1,20 @@
package com.ruoyi.out.relocation.domain;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import com.ruoyi.out.dense.domain.DenseLanePosition;
/**
* 指定移库外侧托盘预览。
*/
public class RelocationPreview
{
private List<DenseLanePosition> trays = new ArrayList<>();
private BigDecimal totalQuantity = BigDecimal.ZERO;
public List<DenseLanePosition> getTrays() { return trays; }
public void setTrays(List<DenseLanePosition> trays) { this.trays = trays; }
public BigDecimal getTotalQuantity() { return totalQuantity; }
public void setTotalQuantity(BigDecimal totalQuantity) { this.totalQuantity = totalQuantity; }
}

View File

@@ -0,0 +1,48 @@
package com.ruoyi.out.relocation.mapper;
import java.util.List;
import java.util.Map;
import com.ruoyi.in.job.domain.MydoJob;
import com.ruoyi.in.jobdetail.domain.MydoJobDetail;
import com.ruoyi.out.dense.domain.DenseLanePosition;
import com.ruoyi.out.relocation.domain.RelocationGenerateRequest;
import org.apache.ibatis.annotations.Param;
/**
* 密集库移库作业 Mapper。
*/
public interface RelocationJobMapper
{
MydoJob selectJobForUpdate(@Param("jobId") String jobId);
List<MydoJobDetail> selectStockDetailsForUpdate(@Param("cargoSpaceId") String cargoSpaceId,
@Param("trayCode") String trayCode);
List<DenseLanePosition> selectRelocationSourcesForUpdate(RelocationGenerateRequest request);
int reserveTarget(Map<String, Object> params);
int reserveSource(Map<String, Object> params);
int insertRelocationJob(MydoJob job);
int insertRelocationDetail(MydoJobDetail detail);
int lockRelocationState(MydoJob job);
int moveTrayStock(MydoJob job);
int completeCargoSpaces(MydoJob job);
int archiveRelocationDetails(@Param("jobId") String jobId);
int archiveRelocationJob(@Param("jobId") String jobId);
int deleteRelocationDetails(@Param("jobId") String jobId);
int deleteRelocationJob(@Param("jobId") String jobId);
Integer isFinishedRelocation(@Param("jobId") String jobId);
String selectNextSourceLaneJob(@Param("cargoSpaceId") String cargoSpaceId);
}

View File

@@ -0,0 +1,20 @@
package com.ruoyi.out.relocation.service;
import java.util.List;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.out.relocation.domain.RelocationGenerateRequest;
import com.ruoyi.out.relocation.domain.RelocationPreview;
/**
* 密集库移库作业服务。
*/
public interface IRelocationJobService
{
List<String> createBlockerRelocations(String blockedJobId);
RelocationPreview preview(RelocationGenerateRequest request);
List<String> generate(RelocationGenerateRequest request);
AjaxResult completeFromWcs(String jobId);
}

View File

@@ -0,0 +1,301 @@
package com.ruoyi.out.relocation.service.impl;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import com.ruoyi.bas.cargospace.domain.BasCargoSpace;
import com.ruoyi.bas.id.job.service.JobIdService;
import com.ruoyi.bas.id.taskno.service.TaskNoService;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.in.job.domain.MydoJob;
import com.ruoyi.in.jobdetail.domain.MydoJobDetail;
import com.ruoyi.out.dense.domain.DenseLanePosition;
import com.ruoyi.out.dense.domain.RelocationTargetQuery;
import com.ruoyi.out.dense.service.IDenseLaneService;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
import com.ruoyi.out.dispatch.service.IOutboundDispatchService;
import com.ruoyi.out.relocation.domain.RelocationGenerateRequest;
import com.ruoyi.out.relocation.domain.RelocationPreview;
import com.ruoyi.out.relocation.mapper.RelocationJobMapper;
import com.ruoyi.out.relocation.service.IRelocationJobService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/**
* 密集库移库作业服务实现。
*/
@Service
public class RelocationJobServiceImpl implements IRelocationJobService
{
private final RelocationJobMapper mapper;
private final IDenseLaneService denseLaneService;
private final JobIdService jobIdService;
private final TaskNoService taskNoService;
private final IOutboundDispatchService dispatchService;
public RelocationJobServiceImpl(RelocationJobMapper mapper,
IDenseLaneService denseLaneService,
JobIdService jobIdService,
TaskNoService taskNoService,
IOutboundDispatchService dispatchService)
{
this.mapper = mapper;
this.denseLaneService = denseLaneService;
this.jobIdService = jobIdService;
this.taskNoService = taskNoService;
this.dispatchService = dispatchService;
}
@Override
@Transactional(rollbackFor = Exception.class)
public List<String> createBlockerRelocations(String blockedJobId)
{
if (!StringUtils.hasText(blockedJobId)) {
throw new RuntimeException("被遮挡作业ID不能为空");
}
MydoJob blockedJob = mapper.selectJobForUpdate(blockedJobId.trim());
if (blockedJob == null) {
throw new RuntimeException("被遮挡出库作业不存在");
}
List<DenseLanePosition> blockers = denseLaneService.selectBlockersForUpdate(
blockedJob.getCargoSpaceId());
return createRelocations(blockers, blockedJob.getWarehouseName(), null);
}
@Override
@Transactional(readOnly = true)
public RelocationPreview preview(RelocationGenerateRequest request)
{
validateRequest(request);
List<DenseLanePosition> candidates = mapper.selectRelocationSourcesForUpdate(request);
if (candidates == null || candidates.size() < request.getTrayCount()) {
throw new RuntimeException("源通道可移库托盘数量不足");
}
List<DenseLanePosition> selected = denseLaneService.orderOutermostFirst(candidates)
.stream().limit(request.getTrayCount()).collect(Collectors.toList());
RelocationPreview preview = new RelocationPreview();
preview.setTrays(selected);
BigDecimal total = selected.stream()
.map(DenseLanePosition::getStockQuantity)
.filter(quantity -> quantity != null)
.reduce(BigDecimal.ZERO, BigDecimal::add);
preview.setTotalQuantity(total);
return preview;
}
@Override
@Transactional(rollbackFor = Exception.class)
public List<String> generate(RelocationGenerateRequest request)
{
validateRequest(request);
List<DenseLanePosition> candidates = mapper.selectRelocationSourcesForUpdate(request);
if (candidates == null || candidates.size() < request.getTrayCount()) {
throw new RuntimeException("源通道可移库托盘数量不足");
}
List<DenseLanePosition> selected = denseLaneService.orderOutermostFirst(candidates)
.stream().limit(request.getTrayCount()).collect(Collectors.toList());
return createRelocations(selected, null, request.getTargetGroupCoding());
}
@Override
@Transactional(rollbackFor = Exception.class)
public AjaxResult completeFromWcs(String jobId)
{
if (!StringUtils.hasText(jobId)) {
return AjaxResult.error("移库作业ID不能为空");
}
MydoJob job = mapper.selectJobForUpdate(jobId.trim());
if (job == null) {
if (Integer.valueOf(1).equals(mapper.isFinishedRelocation(jobId.trim()))) {
return AjaxResult.success("移库作业已完成");
}
return AjaxResult.error("移库作业不存在");
}
if (!Long.valueOf(23L).equals(job.getInOrOut())) {
return AjaxResult.error("当前作业不是移库作业");
}
if (!"3".equals(job.getStatus()) || !"1".equals(job.getExecutionPhase())) {
return AjaxResult.error("移库作业尚未成功下发WCS");
}
if (mapper.lockRelocationState(job) != 1) {
throw new RuntimeException("移库源货位、目标货位或托盘状态已变化");
}
if (mapper.moveTrayStock(job) <= 0) {
throw new RuntimeException("移库托盘库存不存在");
}
if (mapper.completeCargoSpaces(job) != 2) {
throw new RuntimeException("移库货位状态更新失败");
}
mapper.archiveRelocationDetails(job.getJobId());
if (mapper.archiveRelocationJob(job.getJobId()) != 1) {
throw new RuntimeException("移库作业归档失败");
}
mapper.deleteRelocationDetails(job.getJobId());
if (mapper.deleteRelocationJob(job.getJobId()) != 1) {
throw new RuntimeException("移库活动作业删除失败");
}
afterCommit(() -> dispatchNextSourceLaneJob(job.getCargoSpaceId()));
return AjaxResult.success("移库作业完成");
}
private List<String> createRelocations(List<DenseLanePosition> sources,
String warehouseName,
String targetGroupCoding)
{
if (sources == null || sources.isEmpty()) {
return new ArrayList<>();
}
List<BasCargoSpace> targets = new ArrayList<>();
for (DenseLanePosition source : sources) {
RelocationTargetQuery query = new RelocationTargetQuery();
query.setWarehouseId(source.getWarehouseId());
query.setTheFloor(source.getTheFloor());
query.setSourceGroupCoding(source.getGroupCoding());
query.setTargetGroupCoding(targetGroupCoding);
query.setTrayCode(source.getTrayCode());
BasCargoSpace target = denseLaneService.selectRelocationTargetForUpdate(query);
if (target == null) {
throw new RuntimeException("同层可用移库货位不足");
}
Map<String, Object> reserve = new HashMap<>();
reserve.put("cargoSpaceId", target.getCargoSpaceId());
reserve.put("trayCode", source.getTrayCode());
if (mapper.reserveTarget(reserve) != 1) {
throw new RuntimeException("移库目标货位已被占用");
}
targets.add(target);
}
List<String> jobIds = new ArrayList<>();
for (int index = 0; index < sources.size(); index++) {
DenseLanePosition source = sources.get(index);
BasCargoSpace target = targets.get(index);
Map<String, Object> sourceReserve = new HashMap<>();
sourceReserve.put("cargoSpaceId", source.getCargoSpaceId());
sourceReserve.put("trayCode", source.getTrayCode());
if (mapper.reserveSource(sourceReserve) != 1) {
throw new RuntimeException("移库源货位状态已变化");
}
List<MydoJobDetail> stockDetails = mapper.selectStockDetailsForUpdate(
source.getCargoSpaceId(), source.getTrayCode());
if (stockDetails == null || stockDetails.isEmpty()) {
throw new RuntimeException("移库托盘没有库存明细");
}
String jobId = jobIdService.getId("CK");
if (!StringUtils.hasText(jobId)) {
throw new RuntimeException("移库作业ID生成失败");
}
MydoJob job = buildJob(jobId, taskNoService.getTaskNo("23"), source,
target, warehouseName);
if (mapper.insertRelocationJob(job) != 1) {
throw new RuntimeException("移库作业生成失败");
}
int detailIndex = 1;
for (MydoJobDetail template : stockDetails) {
MydoJobDetail detail = copyDetail(template, jobId, detailIndex++);
if (mapper.insertRelocationDetail(detail) != 1) {
throw new RuntimeException("移库作业明细生成失败");
}
}
jobIds.add(jobId);
}
afterCommit(() -> jobIds.forEach(id -> dispatchService.dispatch(id, DispatchTrigger.INITIAL)));
return jobIds;
}
private MydoJob buildJob(String jobId, String taskNo, DenseLanePosition source,
BasCargoSpace target, String warehouseName)
{
MydoJob job = new MydoJob();
job.setJobId(jobId);
job.setTaskNo(StringUtils.hasText(taskNo) ? taskNo : jobId);
job.setInOrOut(23L);
job.setStatus("1");
job.setExecutionPhase("0");
job.setTrayCode(source.getTrayCode());
job.setWarehouseId(source.getWarehouseId());
job.setWarehouseName(warehouseName);
job.setCargoSpaceId(source.getCargoSpaceId());
job.setCargoSpaceName(source.getCargoSpaceName());
job.setTargetCargoSpaceId(target.getCargoSpaceId());
job.setTargetCargoSpaceName(target.getCargoSpaceName());
job.setLineType("1");
job.setPriority(10L);
job.setCreateTime(DateUtils.getNowDate());
return job;
}
private MydoJobDetail copyDetail(MydoJobDetail source, String jobId, int index)
{
MydoJobDetail detail = new MydoJobDetail();
detail.setJobDetailId(jobId + "-" + index);
detail.setJobId(jobId);
detail.setStockId(source.getStockId());
detail.setLotNo(source.getLotNo());
detail.setInDate(source.getInDate());
detail.setInOrOutId(source.getInOrOutId());
detail.setInOrOutDId(source.getInOrOutDId());
detail.setProductId(source.getProductId());
detail.setProductName(source.getProductName());
detail.setStockQuantity(source.getStockQuantity());
detail.setStockinId(source.getStockinId());
detail.setStockinDetailId(source.getStockinDetailId());
detail.setCustomerId(source.getCustomerId());
detail.setCustomerName(source.getCustomerName());
detail.setSupplierId(source.getSupplierId());
detail.setSupplierName(source.getSupplierName());
detail.setProjectNo(source.getProjectNo());
return detail;
}
private void validateRequest(RelocationGenerateRequest request)
{
if (request == null || !"03".equals(request.getWarehouseId())) {
throw new RuntimeException("指定移库仅支持03仓密集库");
}
if (request.getTheFloor() == null) {
throw new RuntimeException("请选择移库层");
}
if (!StringUtils.hasText(request.getSourceGroupCoding())
|| !StringUtils.hasText(request.getTargetGroupCoding())) {
throw new RuntimeException("请选择源通道和目标通道");
}
if (request.getSourceGroupCoding().equals(request.getTargetGroupCoding())) {
throw new RuntimeException("移库目标必须是同层不同通道");
}
if (request.getTrayCount() == null || request.getTrayCount() <= 0) {
throw new RuntimeException("移库托盘数量必须大于0");
}
}
private void dispatchNextSourceLaneJob(String sourceCargoSpaceId)
{
String nextJobId = mapper.selectNextSourceLaneJob(sourceCargoSpaceId);
if (StringUtils.hasText(nextJobId)) {
dispatchService.dispatch(nextJobId, DispatchTrigger.INITIAL);
}
}
private void afterCommit(Runnable action)
{
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization()
{
@Override
public void afterCommit()
{
action.run();
}
});
} else {
action.run();
}
}
}

View File

@@ -14,6 +14,9 @@ import com.ruoyi.in.job.domain.MydoJob;
import com.ruoyi.in.job.service.IMydoJobService;
import com.ruoyi.out.stockout.domain.OutStockoutAssignDTO;
import com.ruoyi.out.stockout.domain.OutStockoutManualAssignDTO;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
import com.ruoyi.out.dispatch.service.IOutboundDispatchService;
import com.ruoyi.out.relocation.service.IRelocationJobService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -57,6 +60,12 @@ public class OutStockoutServiceImpl implements IOutStockoutService
@Autowired
private BasCargoSpaceMapper basCargoSpaceMapper;
@Autowired
private IRelocationJobService relocationJobService;
@Autowired
private IOutboundDispatchService dispatchService;
/**
* 查询出库单据主信息
*/
@@ -334,7 +343,7 @@ public class OutStockoutServiceImpl implements IOutStockoutService
}
/**
* 将新生成的出库作业推送到 WCS。推送成功后把作业状态从未执行改为待执行,避免重复下发。
* 为新生成作业准备密集库遮挡移库,并统一通过 Outbox 下发 WCS。
*/
private AjaxResult pushPendingOutJobs(String stockoutDetailId)
{
@@ -344,11 +353,16 @@ public class OutStockoutServiceImpl implements IOutStockoutService
}
StringBuilder message = new StringBuilder();
for (MydoJob job : jobs) {
AjaxResult pushResult = mydoJobService.pushOutJobToWcs(job.getJobId());
List<String> relocationIds = relocationJobService.createBlockerRelocations(job.getJobId());
AjaxResult pushResult = dispatchService.dispatch(job.getJobId(), DispatchTrigger.INITIAL);
if (pushResult == null || pushResult.isError()) {
return AjaxResult.error(pushResult == null ? "WCS出库作业下发失败" : Convert.toStr(pushResult.get(AjaxResult.MSG_TAG)));
}
message.append(job.getJobId()).append(":").append(Convert.toStr(pushResult.get(AjaxResult.MSG_TAG))).append("|");
message.append(job.getJobId()).append(":");
if (relocationIds != null && !relocationIds.isEmpty()) {
message.append("已生成").append(relocationIds.size()).append("个遮挡移库作业;");
}
message.append(Convert.toStr(pushResult.get(AjaxResult.MSG_TAG))).append("|");
}
return AjaxResult.success(message.toString());
}

View File

@@ -176,8 +176,10 @@ external:
'02': ${WCS_02_BASE_URL:${WCS_DEFAULT_BASE_URL:http://172.16.4.63:8084}}
'03': ${WCS_03_BASE_URL:${WCS_DEFAULT_BASE_URL:http://172.16.4.63:8084}}
apis:
sendPutawayTask: /wcs/api/receiveWmsJob
sendRetrieveTask: /wcs/api/receiveWmsJob
sendPutawayTask: ${WCS_PUTAWAY_PATH:/wcs/api/receiveWmsJob}
sendRetrieveTask: ${WCS_OUTBOUND_PATH:/wcs/api/receiveWmsOutJob}
sendLocationOutboundTask: ${WCS_LOCATION_OUTBOUND_PATH:/wcs/api/receiveWmsLocationOutJob}
sendRelocationTask: ${WCS_RELOCATION_PATH:/wcs/api/receiveWmsMoveJob}
queryDeviceStatus: /device/status
sendCraneMove: /crane/move
cancelTask: /task/cancel

View File

@@ -36,12 +36,20 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="supplierName" column="supplier_name" />
<result property="customerId" column="customer_id" />
<result property="customerName" column="customer_name" />
<result property="dispatchStatus" column="dispatch_status" />
<result property="autoRetryCount" column="auto_retry_count" />
<result property="manualRetryCount" column="manual_retry_count" />
<result property="lastDispatchError" column="last_dispatch_error" />
</resultMap>
<sql id="selectMydoJobVo">
select job_id, status, task_no, in_or_out, execution_phase, tray_code, warehouse_id,
select mydo_job.job_id, status, task_no, in_or_out, execution_phase, tray_code, warehouse_id,
isnull(nullif(warehouse_name, ''), (select top 1 bw.warehouse_name from bas_warehouse bw where bw.warehouse_id = mydo_job.warehouse_id)) as warehouse_name,
roadway_id, cargo_space_id, cargo_space_name, target_roadway_id, target_cargo_space_id, target_cargo_space_name, line_type, priority, height, weight, create_time, start_time, finish_time, entrance_id, exit_id, dept_id, dept_name, user_id, user_name, supplier_id, supplier_name, customer_id, customer_name from mydo_job
roadway_id, cargo_space_id, cargo_space_name, target_roadway_id, target_cargo_space_id, target_cargo_space_name, line_type, priority, height, weight, mydo_job.create_time, start_time, finish_time, entrance_id, exit_id, dept_id, dept_name, user_id, user_name, supplier_id, supplier_name, customer_id, customer_name,
wjd.dispatch_status as dispatch_status, wjd.auto_retry_count as auto_retry_count,
wjd.manual_retry_count as manual_retry_count, wjd.last_error as last_dispatch_error
from mydo_job
left join wcs_job_dispatch wjd on wjd.job_id = mydo_job.job_id
</sql>
<sql id="selectMydoJobFinishVo">
select job_id, status, task_no, in_or_out, execution_phase, tray_code, warehouse_id,
@@ -66,7 +74,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="priority != null "> and priority = #{priority}</if>
<if test="height != null "> and height = #{height}</if>
<if test="weight != null "> and weight = #{weight}</if>
<if test="params.beginCreateTime != null and params.beginCreateTime != '' and params.endCreateTime != null and params.endCreateTime != ''"> and create_time between #{params.beginCreateTime} and #{params.endCreateTime}</if>
<if test="params.beginCreateTime != null and params.beginCreateTime != '' and params.endCreateTime != null and params.endCreateTime != ''"> and mydo_job.create_time between #{params.beginCreateTime} and #{params.endCreateTime}</if>
<if test="entranceId != null and entranceId != ''"> and entrance_id = #{entranceId}</if>
<if test="exitId != null and exitId != ''"> and exit_id = #{exitId}</if>
<if test="deptName != null and deptName != ''"> and dept_name like concat('%', #{deptName}, '%')</if>
@@ -79,7 +87,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectMydoJobByJobId" parameterType="String" resultMap="MydoJobResult">
<include refid="selectMydoJobVo"/>
where job_id = #{jobId}
where mydo_job.job_id = #{jobId}
</select>
<select id="acquirePutOccupiedJobLock" parameterType="java.util.Map" resultType="java.lang.Integer">
@@ -422,7 +430,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="priority != null "> and priority = #{priority}</if>
<if test="height != null "> and height = #{height}</if>
<if test="weight != null "> and weight = #{weight}</if>
<if test="params.beginCreateTime != null and params.beginCreateTime != '' and params.endCreateTime != null and params.endCreateTime != ''"> and create_time between #{params.beginCreateTime} and #{params.endCreateTime}</if>
<if test="params.beginCreateTime != null and params.beginCreateTime != '' and params.endCreateTime != null and params.endCreateTime != ''"> and mydo_job.create_time between #{params.beginCreateTime} and #{params.endCreateTime}</if>
<if test="entranceId != null and entranceId != ''"> and entrance_id = #{entranceId}</if>
<if test="exitId != null and exitId != ''"> and exit_id = #{exitId}</if>
<if test="deptName != null and deptName != ''"> and dept_name like concat('%', #{deptName}, '%')</if>
@@ -476,7 +484,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="priority != null "> and priority = #{priority}</if>
<if test="height != null "> and height = #{height}</if>
<if test="weight != null "> and weight = #{weight}</if>
<if test="params.beginCreateTime != null and params.beginCreateTime != '' and params.endCreateTime != null and params.endCreateTime != ''"> and create_time between #{params.beginCreateTime} and #{params.endCreateTime}</if>
<if test="params.beginCreateTime != null and params.beginCreateTime != '' and params.endCreateTime != null and params.endCreateTime != ''"> and mydo_job.create_time between #{params.beginCreateTime} and #{params.endCreateTime}</if>
<if test="entranceId != null and entranceId != ''"> and entrance_id = #{entranceId}</if>
<if test="exitId != null and exitId != ''"> and exit_id = #{exitId}</if>
<if test="deptName != null and deptName != ''"> and dept_name like concat('%', #{deptName}, '%')</if>
@@ -586,7 +594,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="priority != null "> and priority = #{priority}</if>
<if test="height != null "> and height = #{height}</if>
<if test="weight != null "> and weight = #{weight}</if>
<if test="params.beginCreateTime != null and params.beginCreateTime != '' and params.endCreateTime != null and params.endCreateTime != ''"> and create_time between #{params.beginCreateTime} and #{params.endCreateTime}</if>
<if test="params.beginCreateTime != null and params.beginCreateTime != '' and params.endCreateTime != null and params.endCreateTime != ''"> and mydo_job.create_time between #{params.beginCreateTime} and #{params.endCreateTime}</if>
<if test="entranceId != null and entranceId != ''"> and entrance_id = #{entranceId}</if>
<if test="exitId != null and exitId != ''"> and exit_id = #{exitId}</if>
<if test="deptName != null and deptName != ''"> and dept_name like concat('%', #{deptName}, '%')</if>
@@ -614,7 +622,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="priority != null "> and priority = #{priority}</if>
<if test="height != null "> and height = #{height}</if>
<if test="weight != null "> and weight = #{weight}</if>
<if test="params.beginCreateTime != null and params.beginCreateTime != '' and params.endCreateTime != null and params.endCreateTime != ''"> and create_time between #{params.beginCreateTime} and #{params.endCreateTime}</if>
<if test="params.beginCreateTime != null and params.beginCreateTime != '' and params.endCreateTime != null and params.endCreateTime != ''"> and mydo_job.create_time between #{params.beginCreateTime} and #{params.endCreateTime}</if>
<if test="entranceId != null and entranceId != ''"> and entrance_id = #{entranceId}</if>
<if test="exitId != null and exitId != ''"> and exit_id = #{exitId}</if>
<if test="deptName != null and deptName != ''"> and dept_name like concat('%', #{deptName}, '%')</if>

View File

@@ -0,0 +1,466 @@
<?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.out.dense.mapper.DenseLaneMapper">
<resultMap id="DenseLanePositionResult" type="com.ruoyi.out.dense.domain.DenseLanePosition">
<id property="cargoSpaceId" column="cargo_space_id" />
<result property="cargoSpaceName" column="cargo_space_name" />
<result property="warehouseId" column="warehouse_id" />
<result property="groupCoding" column="group_coding" />
<result property="cargoArea" column="this_cargo_area" />
<result property="thePlatoon" column="the_platoon" />
<result property="theColumn" column="the_column" />
<result property="theFloor" column="the_floor" />
<result property="status" column="status" />
<result property="locked" column="locked" />
<result property="trayCode" column="tray_code" />
<result property="directionType" column="direction_type" />
<result property="minColumn" column="min_column" />
<result property="maxColumn" column="max_column" />
</resultMap>
<resultMap id="DenseLaneOptionResult" type="com.ruoyi.out.dense.domain.DenseLaneOption">
<id property="groupCoding" column="group_coding" />
<result property="cargoArea" column="cargo_area" />
<result property="thePlatoon" column="the_platoon" />
<result property="theFloor" column="the_floor" />
<result property="directionType" column="direction_type" />
<result property="fullTrayCount" column="full_tray_count" />
<result property="emptyLocationCount" column="empty_location_count" />
<result property="totalQuantity" column="total_quantity" />
</resultMap>
<resultMap id="DenseLaneSummaryResult" type="com.ruoyi.out.dense.domain.DenseLaneSummary">
<id property="groupCoding" column="group_coding" />
<result property="cargoArea" column="cargo_area" />
<result property="thePlatoon" column="the_platoon" />
<result property="theFloor" column="the_floor" />
<result property="directionType" column="direction_type" />
<result property="fullTrayCount" column="full_tray_count" />
<result property="availableTrayCount" column="available_tray_count" />
<result property="totalQuantity" column="total_quantity" />
<result property="outermostTrayCode" column="outermost_tray_code" />
<result property="outermostCargoSpaceId" column="outermost_cargo_space_id" />
</resultMap>
<select id="selectPosition" resultMap="DenseLanePositionResult">
select source_space.cargo_space_id,
source_space.cargo_space_name,
source_space.warehouse_id,
source_space.group_coding,
source_space.this_cargo_area,
source_space.the_platoon,
source_space.the_column,
source_space.the_floor,
source_space.status,
source_space.locked,
source_space.tray_code,
isnull(area_config.direction_type, '1') as direction_type,
boundary.min_column,
boundary.max_column
from bas_cargo_space source_space
left join bas_cargo_area_config area_config
on area_config.warehouse_id = source_space.warehouse_id
and area_config.cargo_area_code = source_space.this_cargo_area
and area_config.is_active = '1'
outer apply
(
select min(the_column) as min_column,
max(the_column) as max_column
from bas_cargo_space lane_space
where lane_space.warehouse_id = source_space.warehouse_id
and lane_space.group_coding = source_space.group_coding
and lane_space.the_floor = source_space.the_floor
) boundary
where source_space.cargo_space_id = #{cargoSpaceId}
</select>
<select id="selectOccupiedLanePositions" resultMap="DenseLanePositionResult">
select occupied.cargo_space_id,
occupied.cargo_space_name,
occupied.warehouse_id,
occupied.group_coding,
occupied.this_cargo_area,
occupied.the_platoon,
occupied.the_column,
occupied.the_floor,
occupied.status,
occupied.locked,
occupied.tray_code,
isnull(area_config.direction_type, '1') as direction_type,
boundary.min_column,
boundary.max_column
from bas_cargo_space occupied
left join bas_cargo_area_config area_config
on area_config.warehouse_id = occupied.warehouse_id
and area_config.cargo_area_code = occupied.this_cargo_area
and area_config.is_active = '1'
cross apply
(
select min(lane_space.the_column) as min_column,
max(lane_space.the_column) as max_column
from bas_cargo_space lane_space
where lane_space.warehouse_id = occupied.warehouse_id
and lane_space.group_coding = occupied.group_coding
and lane_space.the_floor = occupied.the_floor
) boundary
where occupied.warehouse_id = #{warehouseId}
and occupied.group_coding = #{groupCoding}
and occupied.the_floor = #{theFloor}
and occupied.status in ('2', '3', '4')
and occupied.tray_code is not null
and ltrim(rtrim(occupied.tray_code)) != ''
</select>
<select id="selectBlockersForUpdate" resultMap="DenseLanePositionResult">
;with source_position as
(
select source_space.warehouse_id,
source_space.group_coding,
source_space.the_floor,
source_space.the_column,
isnull(area_config.direction_type, '1') as direction_type,
boundary.min_column,
boundary.max_column,
case when source_space.the_column - boundary.min_column
&lt;= boundary.max_column - source_space.the_column
then source_space.the_column - boundary.min_column
else boundary.max_column - source_space.the_column end as distance_to_edge
from bas_cargo_space source_space with (UPDLOCK,HOLDLOCK)
left join bas_cargo_area_config area_config
on area_config.warehouse_id = source_space.warehouse_id
and area_config.cargo_area_code = source_space.this_cargo_area
and area_config.is_active = '1'
cross apply
(
select min(lane_space.the_column) as min_column,
max(lane_space.the_column) as max_column
from bas_cargo_space lane_space with (UPDLOCK,HOLDLOCK)
where lane_space.warehouse_id = source_space.warehouse_id
and lane_space.group_coding = source_space.group_coding
and lane_space.the_floor = source_space.the_floor
) boundary
where source_space.cargo_space_id = #{cargoSpaceId}
and source_space.warehouse_id = '03'
and source_space.group_coding is not null
), lane_positions as
(
select occupied.cargo_space_id,
occupied.cargo_space_name,
occupied.warehouse_id,
occupied.group_coding,
occupied.this_cargo_area,
occupied.the_platoon,
occupied.the_column,
occupied.the_floor,
occupied.status,
occupied.locked,
occupied.tray_code,
source.direction_type,
source.min_column,
source.max_column,
case when occupied.the_column - source.min_column
&lt;= source.max_column - occupied.the_column
then occupied.the_column - source.min_column
else source.max_column - occupied.the_column end as distance_to_edge,
source.the_column as source_column,
source.distance_to_edge as source_distance
from bas_cargo_space occupied with (UPDLOCK,HOLDLOCK)
inner join source_position source
on source.warehouse_id = occupied.warehouse_id
and source.group_coding = occupied.group_coding
and source.the_floor = occupied.the_floor
where occupied.status in ('2', '3', '4')
and occupied.tray_code is not null
and ltrim(rtrim(occupied.tray_code)) != ''
and not exists
(
select 1
from mydo_job active_job with (UPDLOCK,HOLDLOCK)
where active_job.in_or_out &gt;= 20
and active_job.status in ('1', '2', '3')
and (active_job.cargo_space_id = occupied.cargo_space_id
or active_job.tray_code = occupied.tray_code)
)
)
select cargo_space_id, cargo_space_name, warehouse_id, group_coding,
this_cargo_area, the_platoon, the_column, the_floor, status,
locked, tray_code, direction_type, min_column, max_column
from lane_positions
where (direction_type = '1' and the_column &gt; source_column)
or (direction_type = '2' and the_column &lt; source_column)
or (direction_type = '3' and
(distance_to_edge &lt; source_distance
or (distance_to_edge = source_distance and the_column &lt; source_column)))
order by case when direction_type = '1' then the_column end desc,
case when direction_type = '2' then the_column end asc,
case when direction_type = '3' then distance_to_edge end asc,
the_column asc
</select>
<select id="selectRelocationTargetForUpdate"
parameterType="com.ruoyi.out.dense.domain.RelocationTargetQuery"
resultMap="com.ruoyi.bas.cargospace.mapper.BasCargoSpaceMapper.BasCargoSpaceResult">
select top 1 target_space.*
from bas_cargo_space target_space with (UPDLOCK,HOLDLOCK,ROWLOCK)
left join bas_cargo_area_config area_config
on area_config.warehouse_id = target_space.warehouse_id
and area_config.cargo_area_code = target_space.this_cargo_area
and area_config.is_active = '1'
cross apply
(
select min(lane_boundary.the_column) as min_column,
max(lane_boundary.the_column) as max_column
from bas_cargo_space lane_boundary with (UPDLOCK,HOLDLOCK)
where lane_boundary.warehouse_id = target_space.warehouse_id
and lane_boundary.group_coding = target_space.group_coding
and lane_boundary.the_floor = target_space.the_floor
) boundary
cross apply
(
select case when target_space.the_column - boundary.min_column
&lt;= boundary.max_column - target_space.the_column
then target_space.the_column - boundary.min_column
else boundary.max_column - target_space.the_column end as distance_to_edge
) geometry
where target_space.warehouse_id = #{warehouseId}
and target_space.the_floor = #{theFloor}
and target_space.group_coding != #{sourceGroupCoding}
and target_space.group_coding is not null
and target_space.status = '1'
and isnull(target_space.locked, 'N') = 'N'
and target_space.tray_code is null
<if test="targetGroupCoding != null and targetGroupCoding != ''">
and target_space.group_coding = #{targetGroupCoding}
</if>
<if test="cargoSpaceType != null and cargoSpaceType != ''">
and target_space.the_type = #{cargoSpaceType}
</if>
<if test="height != null">
and target_space.the_height &gt;= #{height}
</if>
and not exists
(
select 1
from mydo_job active_job with (UPDLOCK,HOLDLOCK)
where active_job.status in ('1', '2', '3')
and (active_job.cargo_space_id = target_space.cargo_space_id
or active_job.target_cargo_space_id = target_space.cargo_space_id)
)
order by case when isnull(area_config.direction_type, '1') = '1'
then target_space.the_column end asc,
case when area_config.direction_type = '2'
then target_space.the_column end desc,
case when area_config.direction_type = '3'
then geometry.distance_to_edge end asc,
target_space.group_coding asc,
target_space.cargo_space_id asc
</select>
<select id="selectLaneOptions" resultMap="DenseLaneOptionResult">
;with reserved_targets as
(
select distinct target_cargo_space_id as cargo_space_id
from mydo_job
where status in ('1', '2', '3')
and target_cargo_space_id is not null
), stock_totals as
(
select stock_space.group_coding,
stock_space.the_floor,
sum(isnull(detail.quantity, 0)) as total_quantity
from bas_cargo_space stock_space
inner join bas_stock stock on stock.cargo_space_id = stock_space.cargo_space_id
inner join bas_stock_detail detail on detail.stock_id = stock.stock_id
where stock_space.warehouse_id = #{warehouseId}
and stock_space.the_floor = #{theFloor}
group by stock_space.group_coding, stock_space.the_floor
)
select lane.group_coding,
min(lane.this_cargo_area) as cargo_area,
min(lane.the_platoon) as the_platoon,
lane.the_floor,
isnull(min(config.direction_type), '1') as direction_type,
sum(case when lane.status = '2' and lane.tray_code is not null then 1 else 0 end) as full_tray_count,
sum(case when lane.status = '1'
and isnull(lane.locked, 'N') = 'N'
and lane.tray_code is null
and reserved.cargo_space_id is null then 1 else 0 end) as empty_location_count,
isnull(max(stock_totals.total_quantity), 0) as total_quantity
from bas_cargo_space lane
left join bas_cargo_area_config config
on config.warehouse_id = lane.warehouse_id
and config.cargo_area_code = lane.this_cargo_area
and config.is_active = '1'
left join reserved_targets reserved on reserved.cargo_space_id = lane.cargo_space_id
left join stock_totals
on stock_totals.group_coding = lane.group_coding
and stock_totals.the_floor = lane.the_floor
where lane.warehouse_id = #{warehouseId}
and lane.the_floor = #{theFloor}
and lane.group_coding is not null
and ltrim(rtrim(lane.group_coding)) != ''
group by lane.group_coding, lane.the_floor
order by lane.group_coding
</select>
<select id="selectLaneSummaries" resultMap="DenseLaneSummaryResult">
;with lane_positions as
(
select cargo.cargo_space_id,
cargo.warehouse_id,
cargo.group_coding,
cargo.this_cargo_area,
cargo.the_platoon,
cargo.the_column,
cargo.the_floor,
cargo.status,
cargo.tray_code,
isnull(config.direction_type, '1') as direction_type,
min(cargo.the_column) over
(partition by cargo.group_coding, cargo.the_floor) as min_column,
max(cargo.the_column) over
(partition by cargo.group_coding, cargo.the_floor) as max_column,
case when exists
(
select 1 from mydo_job active_job
where active_job.status in ('1', '2', '3')
and active_job.in_or_out &gt;= 20
and (active_job.cargo_space_id = cargo.cargo_space_id
or active_job.tray_code = cargo.tray_code)
) then 1 else 0 end as has_active_job
from bas_cargo_space cargo
left join bas_cargo_area_config config
on config.warehouse_id = cargo.warehouse_id
and config.cargo_area_code = cargo.this_cargo_area
and config.is_active = '1'
where cargo.warehouse_id = #{warehouseId}
and cargo.group_coding is not null
and ltrim(rtrim(cargo.group_coding)) != ''
<if test="theFloor != null">
and cargo.the_floor = #{theFloor}
</if>
<if test="groupCoding != null and groupCoding != ''">
and cargo.group_coding like concat('%', #{groupCoding}, '%')
</if>
), lane_totals as
(
select group_coding,
the_floor,
min(this_cargo_area) as cargo_area,
min(the_platoon) as the_platoon,
min(direction_type) as direction_type,
sum(case when status = '2' and tray_code is not null then 1 else 0 end) as full_tray_count,
sum(case when status = '2' and tray_code is not null
and has_active_job = 0 then 1 else 0 end) as available_tray_count
from lane_positions
group by group_coding, the_floor
), stock_totals as
(
select position.group_coding,
position.the_floor,
sum(isnull(detail.quantity, 0)) as total_quantity
from lane_positions position
inner join bas_stock stock on stock.cargo_space_id = position.cargo_space_id
inner join bas_stock_detail detail on detail.stock_id = stock.stock_id
group by position.group_coding, position.the_floor
)
select totals.group_coding,
totals.cargo_area,
totals.the_platoon,
totals.the_floor,
totals.direction_type,
totals.full_tray_count,
totals.available_tray_count,
isnull(stock_totals.total_quantity, 0) as total_quantity,
outer_position.tray_code as outermost_tray_code,
outer_position.cargo_space_id as outermost_cargo_space_id
from lane_totals totals
left join stock_totals
on stock_totals.group_coding = totals.group_coding
and stock_totals.the_floor = totals.the_floor
outer apply
(
select top 1 position.tray_code,
position.cargo_space_id,
case when position.the_column - position.min_column
&lt;= position.max_column - position.the_column
then position.the_column - position.min_column
else position.max_column - position.the_column end as distance_to_edge
from lane_positions position
where position.group_coding = totals.group_coding
and position.the_floor = totals.the_floor
and position.status = '2'
and position.tray_code is not null
and position.has_active_job = 0
order by case when totals.direction_type = '1' then position.the_column end desc,
case when totals.direction_type = '2' then position.the_column end asc,
case when totals.direction_type = '3' then
case when position.the_column - position.min_column
&lt;= position.max_column - position.the_column
then position.the_column - position.min_column
else position.max_column - position.the_column end end asc,
position.the_column asc
) outer_position
where totals.full_tray_count &gt; 0
order by totals.the_floor, totals.group_coding
</select>
<select id="isCurrentOutermost" resultType="java.lang.Integer">
select case
when source_space.warehouse_id != '03'
or source_space.group_coding is null
or ltrim(rtrim(source_space.group_coding)) = '' then 1
when not exists
(
select 1
from bas_cargo_space occupied
cross apply
(
select min(lane_space.the_column) as min_column,
max(lane_space.the_column) as max_column
from bas_cargo_space lane_space
where lane_space.warehouse_id = source_space.warehouse_id
and lane_space.group_coding = source_space.group_coding
and lane_space.the_floor = source_space.the_floor
) lane_boundary
where occupied.warehouse_id = source_space.warehouse_id
and occupied.group_coding = source_space.group_coding
and occupied.the_floor = source_space.the_floor
and occupied.cargo_space_id != source_space.cargo_space_id
and occupied.status in ('2', '3', '4')
and occupied.tray_code is not null
and ltrim(rtrim(occupied.tray_code)) != ''
and
(
(isnull(area_config.direction_type, '1') = '1'
and occupied.the_column &gt; source_space.the_column)
or (area_config.direction_type = '2'
and occupied.the_column &lt; source_space.the_column)
or (area_config.direction_type = '3'
and case
when occupied.the_column - lane_boundary.min_column
&lt;= lane_boundary.max_column - occupied.the_column
then occupied.the_column - lane_boundary.min_column
else lane_boundary.max_column - occupied.the_column
end
&lt; case
when source_space.the_column - lane_boundary.min_column
&lt;= lane_boundary.max_column - source_space.the_column
then source_space.the_column - lane_boundary.min_column
else lane_boundary.max_column - source_space.the_column
end)
)
) then 1
else 0
end
from bas_cargo_space source_space
left join bas_cargo_area_config area_config
on area_config.warehouse_id = source_space.warehouse_id
and area_config.cargo_area_code = source_space.this_cargo_area
and area_config.is_active = '1'
where source_space.cargo_space_id = #{cargoSpaceId}
</select>
</mapper>

View File

@@ -0,0 +1,248 @@
<?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.out.relocation.mapper.RelocationJobMapper">
<resultMap id="DenseLanePositionResult" type="com.ruoyi.out.dense.domain.DenseLanePosition">
<id property="cargoSpaceId" column="cargo_space_id" />
<result property="cargoSpaceName" column="cargo_space_name" />
<result property="warehouseId" column="warehouse_id" />
<result property="groupCoding" column="group_coding" />
<result property="cargoArea" column="cargo_area" />
<result property="thePlatoon" column="the_platoon" />
<result property="theColumn" column="the_column" />
<result property="theFloor" column="the_floor" />
<result property="status" column="status" />
<result property="locked" column="locked" />
<result property="trayCode" column="tray_code" />
<result property="directionType" column="direction_type" />
<result property="minColumn" column="min_column" />
<result property="maxColumn" column="max_column" />
<result property="stockQuantity" column="stock_quantity" />
</resultMap>
<resultMap id="StockDetailResult" type="com.ruoyi.in.jobdetail.domain.MydoJobDetail">
<result property="stockId" column="stock_id" />
<result property="lotNo" column="lot_no" />
<result property="inDate" column="in_date" />
<result property="stockinId" column="stockin_id" />
<result property="stockinDetailId" column="stockin_detail_id" />
<result property="productId" column="product_id" />
<result property="productName" column="product_name" />
<result property="stockQuantity" column="stock_quantity" />
<result property="customerId" column="customer_id" />
<result property="customerName" column="customer_name" />
<result property="supplierId" column="supplier_id" />
<result property="supplierName" column="supplier_name" />
<result property="projectNo" column="project_no" />
</resultMap>
<select id="selectJobForUpdate"
resultMap="com.ruoyi.in.job.mapper.MydoJobMapper.MydoJobResult">
select *
from mydo_job with (UPDLOCK,HOLDLOCK)
where job_id = #{jobId}
</select>
<select id="selectStockDetailsForUpdate"
resultMap="StockDetailResult">
select detail.stock_id,
detail.lot_no,
convert(varchar(19), stock.in_date, 120) as in_date,
stock.stockin_id,
detail.detail_id as stockin_detail_id,
detail.product_id,
detail.product_name,
detail.quantity as stock_quantity,
cast(null as nvarchar(255)) as customer_id,
cast(null as nvarchar(255)) as customer_name,
stock.supplier_id as supplier_id,
stock.supplier_name as supplier_name,
detail.project_no
from bas_stock stock with (UPDLOCK,HOLDLOCK)
inner join bas_stock_detail detail with (UPDLOCK,HOLDLOCK)
on detail.stock_id = stock.stock_id
where stock.cargo_space_id = #{cargoSpaceId}
and stock.tray_code = #{trayCode}
and isnull(detail.quantity, 0) &gt; 0
order by detail.detail_id
</select>
<select id="selectRelocationSourcesForUpdate"
resultMap="DenseLanePositionResult">
select cargo.cargo_space_id,
cargo.cargo_space_name,
cargo.warehouse_id,
cargo.group_coding,
cargo.this_cargo_area as cargo_area,
cargo.the_platoon,
cargo.the_column,
cargo.the_floor,
cargo.status,
cargo.locked,
cargo.tray_code,
isnull(config.direction_type, '1') as direction_type,
boundary.min_column,
boundary.max_column,
isnull(stock_total.stock_quantity, 0) as stock_quantity
from bas_cargo_space cargo with (UPDLOCK,HOLDLOCK)
left join bas_cargo_area_config config
on config.warehouse_id = cargo.warehouse_id
and config.cargo_area_code = cargo.this_cargo_area
and config.is_active = '1'
cross apply
(
select min(lane.the_column) as min_column,
max(lane.the_column) as max_column
from bas_cargo_space lane with (UPDLOCK,HOLDLOCK)
where lane.warehouse_id = cargo.warehouse_id
and lane.group_coding = cargo.group_coding
and lane.the_floor = cargo.the_floor
) boundary
outer apply
(
select sum(isnull(detail.quantity, 0)) as stock_quantity
from bas_stock stock
inner join bas_stock_detail detail on detail.stock_id = stock.stock_id
where stock.cargo_space_id = cargo.cargo_space_id
and stock.tray_code = cargo.tray_code
) stock_total
where cargo.warehouse_id = #{warehouseId}
and cargo.the_floor = #{theFloor}
and cargo.group_coding = #{sourceGroupCoding}
and cargo.status = '2'
and cargo.tray_code is not null
and not exists
(
select 1 from mydo_job active_job with (UPDLOCK,HOLDLOCK)
where active_job.status in ('1', '2', '3')
and (active_job.cargo_space_id = cargo.cargo_space_id
or active_job.tray_code = cargo.tray_code)
)
</select>
<update id="reserveTarget" parameterType="java.util.Map">
update bas_cargo_space
set status = '3', tray_code = #{trayCode}
where cargo_space_id = #{cargoSpaceId}
and status = '1'
and isnull(locked, 'N') = 'N'
and tray_code is null
</update>
<update id="reserveSource" parameterType="java.util.Map">
update bas_cargo_space
set status = '4'
where cargo_space_id = #{cargoSpaceId}
and status in ('2', '3')
and tray_code = #{trayCode}
</update>
<insert id="insertRelocationJob" parameterType="com.ruoyi.in.job.domain.MydoJob">
insert into mydo_job
(job_id, status, task_no, in_or_out, execution_phase, tray_code,
warehouse_id, warehouse_name, cargo_space_id, cargo_space_name,
target_cargo_space_id, target_cargo_space_name, line_type, priority, create_time)
values
(#{jobId}, #{status}, #{taskNo}, #{inOrOut}, #{executionPhase}, #{trayCode},
#{warehouseId}, #{warehouseName}, #{cargoSpaceId}, #{cargoSpaceName},
#{targetCargoSpaceId}, #{targetCargoSpaceName}, #{lineType}, #{priority}, #{createTime})
</insert>
<insert id="insertRelocationDetail" parameterType="com.ruoyi.in.jobdetail.domain.MydoJobDetail">
insert into mydo_job_detail
(job_detail_id, job_id, stock_id, lot_no, in_date, in_or_out_id,
in_or_out_d_id, product_id, product_name, stock_quantity, stockin_id,
stockin_detail_id, customer_id, customer_name, supplier_id, supplier_name, project_no)
values
(#{jobDetailId}, #{jobId}, #{stockId}, #{lotNo}, #{inDate}, #{inOrOutId},
#{inOrOutDId}, #{productId}, #{productName}, #{stockQuantity}, #{stockinId},
#{stockinDetailId}, #{customerId}, #{customerName}, #{supplierId}, #{supplierName}, #{projectNo})
</insert>
<select id="lockRelocationState" resultType="java.lang.Integer">
select case when count(1) = 2 then 1 else 0 end
from bas_cargo_space cargo with (UPDLOCK,HOLDLOCK)
where (cargo.cargo_space_id = #{cargoSpaceId}
and cargo.status = '4' and cargo.tray_code = #{trayCode})
or (cargo.cargo_space_id = #{targetCargoSpaceId}
and cargo.status = '3' and cargo.tray_code = #{trayCode})
</select>
<update id="moveTrayStock" parameterType="com.ruoyi.in.job.domain.MydoJob">
update bas_stock
set cargo_space_id = #{targetCargoSpaceId}
where cargo_space_id = #{cargoSpaceId}
and tray_code = #{trayCode}
</update>
<update id="completeCargoSpaces" parameterType="com.ruoyi.in.job.domain.MydoJob">
update bas_cargo_space
set status = case when cargo_space_id = #{cargoSpaceId} then '1' else '2' end,
tray_code = case when cargo_space_id = #{cargoSpaceId} then null else #{trayCode} end
where (cargo_space_id = #{cargoSpaceId} and status = '4' and tray_code = #{trayCode})
or (cargo_space_id = #{targetCargoSpaceId} and status = '3' and tray_code = #{trayCode})
</update>
<insert id="archiveRelocationDetails">
insert into mydo_job_detail_finish
(job_detail_id, job_id, stock_id, lot_no, in_date, in_or_out_id,
in_or_out_d_id, product_id, product_name, in_quantity, stock_quantity,
out_quantity, stockin_id, stockin_detail_id, customer_id, customer_name,
supplier_id, supplier_name, project_no)
select job_detail_id, job_id, stock_id, lot_no, in_date, in_or_out_id,
in_or_out_d_id, product_id, product_name, in_quantity, stock_quantity,
out_quantity, stockin_id, stockin_detail_id, customer_id, customer_name,
supplier_id, supplier_name, project_no
from mydo_job_detail with (UPDLOCK,HOLDLOCK)
where job_id = #{jobId}
</insert>
<insert id="archiveRelocationJob">
insert into mydo_job_finish
(job_id, status, task_no, in_or_out, execution_phase, tray_code,
warehouse_id, warehouse_name, roadway_id, cargo_space_id, cargo_space_name,
target_roadway_id, target_cargo_space_id, target_cargo_space_name, line_type,
priority, height, weight, create_time, start_time, finish_time, entrance_id,
exit_id, dept_id, dept_name, user_id, user_name, supplier_id, supplier_name,
customer_id, customer_name)
select job_id, '4', task_no, in_or_out, '5', tray_code,
warehouse_id, warehouse_name, roadway_id, cargo_space_id, cargo_space_name,
target_roadway_id, target_cargo_space_id, target_cargo_space_name, line_type,
priority, height, weight, create_time, start_time, getdate(), entrance_id,
exit_id, dept_id, dept_name, user_id, user_name, supplier_id, supplier_name,
customer_id, customer_name
from mydo_job with (UPDLOCK,HOLDLOCK)
where job_id = #{jobId} and in_or_out = 23
</insert>
<delete id="deleteRelocationDetails">
delete from mydo_job_detail where job_id = #{jobId}
</delete>
<delete id="deleteRelocationJob">
delete from mydo_job where job_id = #{jobId} and in_or_out = 23
</delete>
<select id="isFinishedRelocation" resultType="java.lang.Integer">
select case when exists
(select 1 from mydo_job_finish where job_id = #{jobId} and in_or_out = 23)
then 1 else 0 end
</select>
<select id="selectNextSourceLaneJob" resultType="java.lang.String">
select top 1 job.job_id
from mydo_job job
inner join bas_cargo_space job_space on job_space.cargo_space_id = job.cargo_space_id
inner join bas_cargo_space source_space on source_space.cargo_space_id = #{cargoSpaceId}
left join wcs_job_dispatch dispatch on dispatch.job_id = job.job_id
where job.in_or_out &gt;= 20
and job.status = '1'
and job_space.warehouse_id = source_space.warehouse_id
and job_space.group_coding = source_space.group_coding
and job_space.the_floor = source_space.the_floor
and (dispatch.dispatch_status is null or dispatch.dispatch_status != 'SUCCESS')
order by job.priority desc, job.create_time asc, job.job_id asc
</select>
</mapper>

View File

@@ -0,0 +1,103 @@
<?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.out.dispatch.mapper.WcsJobDispatchMapper">
<resultMap type="com.ruoyi.out.dispatch.domain.WcsJobDispatch" id="WcsJobDispatchResult">
<id property="jobId" column="job_id" />
<result property="dispatchStatus" column="dispatch_status" />
<result property="autoRetryCount" column="auto_retry_count" />
<result property="manualRetryCount" column="manual_retry_count" />
<result property="lastError" column="last_error" />
<result property="lastAttemptTime" column="last_attempt_time" />
<result property="nextRetryTime" column="next_retry_time" />
<result property="successTime" column="success_time" />
<result property="versionNo" column="version_no" />
<result property="createTime" column="create_time" />
<result property="updateTime" column="update_time" />
</resultMap>
<insert id="insertIfAbsent" parameterType="String">
if not exists (select 1 from wcs_job_dispatch with (updlock, holdlock) where job_id = #{jobId})
begin
insert into wcs_job_dispatch
(job_id, dispatch_status, auto_retry_count, manual_retry_count, version_no, create_time, update_time)
values
(#{jobId}, 'PENDING', 0, 0, 0, getdate(), getdate())
end
</insert>
<select id="selectByJobId" parameterType="String" resultMap="WcsJobDispatchResult">
select job_id, dispatch_status, auto_retry_count, manual_retry_count, last_error,
last_attempt_time, next_retry_time, success_time, version_no, create_time, update_time
from wcs_job_dispatch
where job_id = #{jobId}
</select>
<select id="selectRetryCandidates" resultMap="WcsJobDispatchResult">
select top (#{limit}) job_id, dispatch_status, auto_retry_count, manual_retry_count, last_error,
last_attempt_time, next_retry_time, success_time, version_no, create_time, update_time
from wcs_job_dispatch with (readpast)
where (dispatch_status = 'RETRY_WAIT'
and auto_retry_count &lt; 5
and (next_retry_time is null or next_retry_time &lt;= #{now}))
or dispatch_status = 'BLOCKED'
or (dispatch_status = 'SENDING'
and last_attempt_time &lt;= dateadd(minute, -5, #{now}))
order by create_time, job_id
</select>
<update id="claimForSend" parameterType="map">
update wcs_job_dispatch
set dispatch_status = 'SENDING',
auto_retry_count = auto_retry_count + case when #{trigger} = 'AUTO_RETRY' then 1 else 0 end,
manual_retry_count = manual_retry_count + case when #{trigger} = 'MANUAL' then 1 else 0 end,
last_attempt_time = #{attemptTime},
next_retry_time = null,
update_time = #{attemptTime},
version_no = version_no + 1
where job_id = #{jobId}
and version_no = #{versionNo}
and dispatch_status in ('PENDING', 'BLOCKED', 'RETRY_WAIT', 'EXHAUSTED', 'SENDING')
and (dispatch_status != 'SENDING'
or last_attempt_time &lt;= dateadd(minute, -5, #{attemptTime}))
and (#{trigger} != 'AUTO_RETRY' or auto_retry_count &lt; 5)
</update>
<update id="markBlocked" parameterType="map">
update wcs_job_dispatch
set dispatch_status = 'BLOCKED', last_error = #{lastError}, next_retry_time = #{nextRetryTime},
update_time = #{updateTime}, version_no = version_no + 1
where job_id = #{jobId}
and version_no = #{versionNo}
and dispatch_status != 'SUCCESS'
</update>
<update id="markRetryWait" parameterType="map">
update wcs_job_dispatch
set dispatch_status = 'RETRY_WAIT', last_error = #{lastError}, next_retry_time = #{nextRetryTime},
update_time = #{updateTime}, version_no = version_no + 1
where job_id = #{jobId}
and version_no = #{versionNo}
and dispatch_status = 'SENDING'
</update>
<update id="markSuccess" parameterType="map">
update wcs_job_dispatch
set dispatch_status = 'SUCCESS', last_error = null, next_retry_time = null,
success_time = #{successTime}, update_time = #{successTime}, version_no = version_no + 1
where job_id = #{jobId}
and version_no = #{versionNo}
and dispatch_status = 'SENDING'
</update>
<update id="markExhausted" parameterType="map">
update wcs_job_dispatch
set dispatch_status = 'EXHAUSTED', last_error = #{lastError}, next_retry_time = null,
update_time = #{updateTime}, version_no = version_no + 1
where job_id = #{jobId}
and version_no = #{versionNo}
and dispatch_status in ('SENDING', 'BLOCKED', 'RETRY_WAIT')
</update>
</mapper>

View File

@@ -35,6 +35,10 @@ class WcsClientTest {
.isEqualTo("MOCK_WCS_RETRIEVE_OK");
assertThat(client.sendRetrieveTask(outboundJob).getData())
.isEqualTo("MOCK_WCS_RETRIEVE_OK");
assertThat(client.sendLocationOutboundTask(outboundJob).getData())
.isEqualTo("MOCK_WCS_LOCATION_OUT_OK");
assertThat(client.sendRelocationTask(outboundJob).getData())
.isEqualTo("MOCK_WCS_RELOCATION_OK");
assertThat(client.sendCraneMove("CR01", "A0101", "A0102", "MOVE").getData())
.isEqualTo("MOCK_WCS_CRANE_MOVE_OK");
assertThat(client.cancelTask("TASK001").getData())
@@ -64,6 +68,10 @@ class WcsClientTest {
.isEqualTo("ACCEPTED");
assertThat(client.sendRetrieveTask(outboundJob).getData())
.isEqualTo("ACCEPTED");
assertThat(client.sendLocationOutboundTask(outboundJob).getData())
.isEqualTo("ACCEPTED");
assertThat(client.sendRelocationTask(outboundJob).getData())
.isEqualTo("ACCEPTED");
assertThat(client.queryDeviceStatus("CRANE").getData().get(0).get("status"))
.isEqualTo("ONLINE");
assertThat(client.sendCraneMove("CR01", "A0101", "A0102", "MOVE").getData())
@@ -75,7 +83,9 @@ class WcsClientTest {
"http://wcs/wcs/api/receiveWmsJob",
"http://wcs/wcs/api/receiveWmsJob",
"http://wcs/task/dispatch",
"http://wcs/wcs/api/receiveWmsJob",
"http://wcs/task/dispatch",
"http://wcs/location/dispatch",
"http://wcs/relocation/dispatch",
"http://wcs/device/status",
"http://wcs/crane/move",
"http://wcs/task/cancel");
@@ -84,6 +94,8 @@ class WcsClientTest {
MsgTypeEnum.JSON,
MsgTypeEnum.TEXT,
MsgTypeEnum.JSON,
MsgTypeEnum.JSON,
MsgTypeEnum.JSON,
MsgTypeEnum.TEXT,
MsgTypeEnum.TEXT,
MsgTypeEnum.TEXT);
@@ -219,6 +231,8 @@ class WcsClientTest {
Map<String, String> apis = new HashMap<>();
apis.put("sendPutawayTask", "/wcs/api/receiveWmsJob");
apis.put("sendRetrieveTask", "/task/dispatch");
apis.put("sendLocationOutboundTask", "/location/dispatch");
apis.put("sendRelocationTask", "/relocation/dispatch");
apis.put("queryDeviceStatus", "/device/status");
apis.put("sendCraneMove", "/crane/move");
apis.put("cancelTask", "/task/cancel");

View File

@@ -88,6 +88,19 @@ class MydoJobMapperSqlTest {
.contains("<if test=\"warehouseId != null and warehouseId != ''\"> and warehouse_id = #{warehouseId}</if>");
}
@Test
void outboundListQualifiesCreateTimeAfterJoiningDispatchOutbox() throws Exception {
String xml = Files.readString(resolveMapperXml());
for (String selectId : new String[] {"selectMydoJobList", "selectInMydoJobList",
"selectInventoryInMydoJobList", "selectOutMydoJobList", "selectInventoryOutMydoJobList"}) {
String activeList = section(xml, "<select id=\"" + selectId + "\"", "</select>");
assertThat(activeList)
.as(selectId)
.contains("mydo_job.create_time between #{params.beginCreateTime} and #{params.endCreateTime}")
.doesNotContain("> and create_time between #{params.beginCreateTime}");
}
}
@Test
void denseInboundJobClaimIsAtomicAndLimitedToPendingWarehouse03Jobs() throws Exception {
String xml = Files.readString(resolveMapperXml());

View File

@@ -20,6 +20,8 @@ import com.ruoyi.in.stockin.domain.InStockin;
import com.ruoyi.in.stockin.mapper.InStockinMapper;
import com.ruoyi.out.stockout.mapper.OutStockoutMapper;
import com.ruoyi.out.stockoutdetail.mapper.OutStockoutDetailMapper;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
import com.ruoyi.out.dispatch.service.IOutboundDispatchService;
import com.ruoyi.pd.diff.mapper.PdDifferenceRecordMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -213,6 +215,9 @@ class MydoJobServiceImplTest {
@Mock
private PdDifferenceRecordMapper pdDifferenceRecordMapper;
@Mock
private IOutboundDispatchService outboundDispatchService;
@InjectMocks
private MydoJobServiceImpl service;
@@ -257,57 +262,16 @@ class MydoJobServiceImplTest {
}
@Test
void pushOutJobToWcsRejectsInboundJob() {
MydoJob job = new MydoJob();
job.setJobId("RK001");
job.setStatus("1");
job.setInOrOut(1L);
when(mydoJobMapper.selectMydoJobByJobId("RK001")).thenReturn(job);
AjaxResult result = service.pushOutJobToWcs("RK001");
assertThat(result.isError()).isTrue();
assertThat(result.get(AjaxResult.MSG_TAG)).isEqualTo("请选择出库作业");
verifyNoInteractions(wcsClient);
verify(mydoJobMapper, never()).updateMydoJob(any(MydoJob.class));
}
@Test
void pushOutJobToWcsDoesNotSendAlreadyPushedJobAgain() {
MydoJob job = new MydoJob();
job.setJobId("CK001");
job.setStatus("2");
job.setInOrOut(20L);
when(mydoJobMapper.selectMydoJobByJobId("CK001")).thenReturn(job);
AjaxResult result = service.pushOutJobToWcs("CK001");
assertThat(result.isSuccess()).isTrue();
assertThat(result.get(AjaxResult.MSG_TAG)).isEqualTo("出库作业已下发,无需重复推送");
verifyNoInteractions(wcsClient);
verify(mydoJobMapper, never()).updateMydoJob(any(MydoJob.class));
}
@Test
void pushOutJobToWcsMarksPendingOutJobAsPushedAfterWcsAcceptsIt() {
MydoJob job = new MydoJob();
job.setJobId("CK002");
job.setStatus("1");
job.setInOrOut(20L);
when(mydoJobMapper.selectMydoJobByJobId("CK002")).thenReturn(job);
when(wcsClient.sendRetrieveTask(job)).thenReturn(ApiResponse.ok("OK"));
when(mydoJobMapper.updateMydoJob(any(MydoJob.class))).thenReturn(1);
void pushOutJobToWcsDelegatesInitialDispatchForExistingBusinessCallers() {
AjaxResult expected = AjaxResult.success("WCS出库作业下发成功:OK");
when(outboundDispatchService.dispatch("CK002", DispatchTrigger.INITIAL)).thenReturn(expected);
AjaxResult result = service.pushOutJobToWcs("CK002");
assertThat(result.isSuccess()).isTrue();
ArgumentCaptor<MydoJob> updateCaptor = ArgumentCaptor.forClass(MydoJob.class);
verify(mydoJobMapper).updateMydoJob(updateCaptor.capture());
MydoJob update = updateCaptor.getValue();
assertThat(update.getJobId()).isEqualTo("CK002");
assertThat(update.getStatus()).isEqualTo("3");
assertThat(update.getExecutionPhase()).isEqualTo("1");
assertThat(update.getStartTime()).isNotBlank();
assertThat(result).isSameAs(expected);
verify(outboundDispatchService).dispatch("CK002", DispatchTrigger.INITIAL);
verifyNoInteractions(mydoJobMapper);
verifyNoInteractions(wcsClient);
}
@Test

View File

@@ -0,0 +1,109 @@
package com.ruoyi.kpi.service.impl;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import com.ruoyi.bas.cargospace.mapper.BasCargoSpaceMapper;
import com.ruoyi.bas.stock.mapper.BasStockDetailMapper;
import com.ruoyi.in.job.mapper.MydoJobMapper;
import com.ruoyi.kpi.mapper.KpiMapper;
import com.ruoyi.out.dense.domain.DenseLaneOption;
import com.ruoyi.out.dense.mapper.DenseLaneMapper;
import com.ruoyi.out.relocation.domain.RelocationGenerateRequest;
import com.ruoyi.out.relocation.domain.RelocationPreview;
import com.ruoyi.out.relocation.service.IRelocationJobService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class KpiDenseRelocationServiceTest
{
@Mock private KpiMapper kpiMapper;
@Mock private BasCargoSpaceMapper basCargoSpaceMapper;
@Mock private BasStockDetailMapper basStockDetailMapper;
@Mock private MydoJobMapper mydoJobMapper;
@Mock private DenseLaneMapper denseLaneMapper;
@Mock private IRelocationJobService relocationJobService;
@InjectMocks
private KpiServiceImpl service;
@Test
void denseRelocationOptionsOnlyAcceptWarehouse03()
{
RelocationGenerateRequest request = request("02", 1L, "A", null, 1);
assertThatThrownBy(() -> service.getDenseRelocationOptions(request))
.hasMessageContaining("03仓");
}
@Test
void optionsReturnFullSourceLanesAndSameFloorTargetsWithEnoughCapacity()
{
DenseLaneOption source = lane("A", 2L, 3, 0);
DenseLaneOption enough = lane("B", 2L, 0, 2);
DenseLaneOption insufficient = lane("C", 2L, 0, 1);
when(denseLaneMapper.selectLaneOptions("03", 2L))
.thenReturn(List.of(source, enough, insufficient));
RelocationGenerateRequest request = request("03", 2L, "A", null, 2);
Map<String, Object> result = service.getDenseRelocationOptions(request);
assertThat(result.get("sourceLanes")).asList().containsExactly(source);
assertThat(result.get("targetLanes")).asList().containsExactly(enough);
}
@Test
void previewReturnsExactOuterTraysAndInventoryTotal()
{
RelocationGenerateRequest request = request("03", 2L, "A", "B", 2);
RelocationPreview preview = new RelocationPreview();
preview.setTotalQuantity(new BigDecimal("25"));
when(relocationJobService.preview(request)).thenReturn(preview);
assertThat(service.previewDenseRelocation(request).getTotalQuantity())
.isEqualByComparingTo("25");
verify(relocationJobService).preview(request);
}
@Test
void generateRevalidatesThroughTransactionalRelocationService()
{
RelocationGenerateRequest request = request("03", 2L, "A", "B", 2);
when(relocationJobService.generate(request)).thenReturn(List.of("MOVE-1", "MOVE-2"));
assertThat(service.generateDenseRelocation(request))
.containsExactly("MOVE-1", "MOVE-2");
verify(relocationJobService).generate(request);
}
private RelocationGenerateRequest request(String warehouseId, Long floor,
String source, String target, int count)
{
RelocationGenerateRequest request = new RelocationGenerateRequest();
request.setWarehouseId(warehouseId);
request.setTheFloor(floor);
request.setSourceGroupCoding(source);
request.setTargetGroupCoding(target);
request.setTrayCount(count);
return request;
}
private DenseLaneOption lane(String groupCoding, Long floor, int full, int empty)
{
DenseLaneOption option = new DenseLaneOption();
option.setGroupCoding(groupCoding);
option.setTheFloor(floor);
option.setFullTrayCount(full);
option.setEmptyLocationCount(empty);
return option;
}
}

View File

@@ -0,0 +1,123 @@
package com.ruoyi.out.dense.mapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class DenseLaneMapperSqlTest
{
@Test
void occupiedLaneQueryUsesAllReservedStatusesAndStableGeometry() throws Exception
{
String xml = Files.readString(resolveMapperXml());
String sql = section(xml, "<select id=\"selectOccupiedLanePositions\"", "</select>");
assertThat(sql)
.contains("status in ('2', '3', '4')")
.contains("tray_code is not null")
.contains("from bas_cargo_space lane_space")
.contains("min(lane_space.the_column) as min_column")
.contains("max(lane_space.the_column) as max_column")
.contains("direction_type");
}
@Test
void blockerQueryLocksRowsOrdersOuterToInnerAndSkipsActiveJobs() throws Exception
{
String xml = Files.readString(resolveMapperXml());
String sql = section(xml, "<select id=\"selectBlockersForUpdate\"", "</select>");
assertThat(sql)
.containsIgnoringCase("UPDLOCK")
.containsIgnoringCase("HOLDLOCK")
.contains("status in ('2', '3', '4')")
.contains("not exists")
.contains("from mydo_job active_job")
.contains("active_job.in_or_out &gt;= 20")
.contains("active_job.status in ('1', '2', '3')")
.contains("case when direction_type = '1' then the_column end desc")
.contains("case when direction_type = '2' then the_column end asc")
.contains("then distance_to_edge end asc")
.contains("the_column asc");
}
@Test
void relocationTargetIsLockedSameFloorDifferentLaneAndUnreserved() throws Exception
{
String xml = Files.readString(resolveMapperXml());
String sql = section(xml, "<select id=\"selectRelocationTargetForUpdate\"", "</select>");
assertThat(sql)
.contains("resultMap=\"com.ruoyi.bas.cargospace.mapper.BasCargoSpaceMapper.BasCargoSpaceResult\"")
.containsIgnoringCase("UPDLOCK")
.containsIgnoringCase("HOLDLOCK")
.contains("warehouse_id = #{warehouseId}")
.contains("the_floor = #{theFloor}")
.contains("group_coding != #{sourceGroupCoding}")
.contains("status = '1'")
.contains("isnull(target_space.locked, 'N') = 'N'")
.contains("target_space.tray_code is null")
.contains("target_cargo_space_id")
.contains("not exists");
}
@Test
void directionThreeOrderingUsesActualLaneBoundaries() throws Exception
{
String xml = Files.readString(resolveMapperXml());
String summarySql = section(xml, "<select id=\"selectLaneSummaries\"", "</select>");
String targetSql = section(xml, "<select id=\"selectRelocationTargetForUpdate\"", "</select>");
assertThat(summarySql)
.contains("min(cargo.the_column) over")
.contains("max(cargo.the_column) over")
.contains("distance_to_edge")
.doesNotContain("abs(position.the_column - 1)");
assertThat(targetSql)
.contains("min(lane_boundary.the_column)")
.contains("max(lane_boundary.the_column)")
.contains("distance_to_edge")
.doesNotContain("abs(target_space.the_column - 1)");
}
@Test
void laneOptionsAndSummariesUseExplicitSnakeCaseResultMaps() throws Exception
{
String xml = Files.readString(resolveMapperXml());
String optionMap = section(xml, "<resultMap id=\"DenseLaneOptionResult\"", "</resultMap>");
String summaryMap = section(xml, "<resultMap id=\"DenseLaneSummaryResult\"", "</resultMap>");
String optionSelect = section(xml, "<select id=\"selectLaneOptions\"", "</select>");
String summarySelect = section(xml, "<select id=\"selectLaneSummaries\"", "</select>");
assertThat(optionMap)
.contains("property=\"groupCoding\" column=\"group_coding\"")
.contains("property=\"emptyLocationCount\" column=\"empty_location_count\"");
assertThat(summaryMap)
.contains("property=\"groupCoding\" column=\"group_coding\"")
.contains("property=\"outermostTrayCode\" column=\"outermost_tray_code\"");
assertThat(optionSelect).contains("resultMap=\"DenseLaneOptionResult\"");
assertThat(summarySelect).contains("resultMap=\"DenseLaneSummaryResult\"");
}
private Path resolveMapperXml()
{
return Stream.of(
Path.of("src/main/resources/mapper/out/DenseLaneMapper.xml"),
Path.of("ruoyi-admin/src/main/resources/mapper/out/DenseLaneMapper.xml"))
.filter(Files::exists)
.findFirst()
.orElseThrow();
}
private String section(String text, String startMarker, String endMarker)
{
int start = text.indexOf(startMarker);
assertThat(start).isGreaterThanOrEqualTo(0);
int end = text.indexOf(endMarker, start);
assertThat(end).isGreaterThan(start);
return text.substring(start, end);
}
}

View File

@@ -0,0 +1,273 @@
package com.ruoyi.out.dense.service.impl;
import java.math.BigDecimal;
import java.util.List;
import com.ruoyi.bas.cargospace.mapper.BasCargoSpaceMapper;
import com.ruoyi.bas.id.job.service.JobIdService;
import com.ruoyi.bas.id.taskno.service.TaskNoService;
import com.ruoyi.bas.stationpoint.domain.BasStationPoint;
import com.ruoyi.bas.stationpoint.service.IBasStationPointService;
import com.ruoyi.bas.stock.mapper.BasStockMapper;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.in.job.domain.MydoJob;
import com.ruoyi.in.job.service.IMydoJobService;
import com.ruoyi.in.jobdetail.domain.MydoJobDetail;
import com.ruoyi.in.jobdetail.service.IMydoJobDetailService;
import com.ruoyi.out.dense.domain.DenseLaneOutboundPreview;
import com.ruoyi.out.dense.domain.DenseLaneOutboundQuery;
import com.ruoyi.out.dense.domain.DenseLaneOutboundRequest;
import com.ruoyi.out.dense.domain.DenseLanePosition;
import com.ruoyi.out.dense.domain.DenseLaneSummary;
import com.ruoyi.out.dense.mapper.DenseLaneMapper;
import com.ruoyi.out.dense.service.IDenseLaneService;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
import com.ruoyi.out.dispatch.service.IOutboundDispatchService;
import com.ruoyi.out.location.mapper.LocationOutboundMapper;
import com.ruoyi.out.relocation.mapper.RelocationJobMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class DenseLaneOutboundServiceImplTest
{
private DenseLaneMapper denseLaneMapper;
private IDenseLaneService denseLaneService;
private RelocationJobMapper relocationJobMapper;
private IBasStationPointService stationPointService;
private IMydoJobService mydoJobService;
private IMydoJobDetailService mydoJobDetailService;
private LocationOutboundMapper locationOutboundMapper;
private BasStockMapper basStockMapper;
private BasCargoSpaceMapper cargoSpaceMapper;
private JobIdService jobIdService;
private TaskNoService taskNoService;
private IOutboundDispatchService dispatchService;
private DenseLaneOutboundServiceImpl service;
@BeforeEach
void setUp()
{
denseLaneMapper = mock(DenseLaneMapper.class);
denseLaneService = mock(IDenseLaneService.class);
relocationJobMapper = mock(RelocationJobMapper.class);
stationPointService = mock(IBasStationPointService.class);
mydoJobService = mock(IMydoJobService.class);
mydoJobDetailService = mock(IMydoJobDetailService.class);
locationOutboundMapper = mock(LocationOutboundMapper.class);
basStockMapper = mock(BasStockMapper.class);
cargoSpaceMapper = mock(BasCargoSpaceMapper.class);
jobIdService = mock(JobIdService.class);
taskNoService = mock(TaskNoService.class);
dispatchService = mock(IOutboundDispatchService.class);
service = new DenseLaneOutboundServiceImpl(denseLaneMapper, denseLaneService,
relocationJobMapper, stationPointService, mydoJobService, mydoJobDetailService,
locationOutboundMapper, basStockMapper, cargoSpaceMapper, jobIdService,
taskNoService, dispatchService);
}
@Test
void listOnlyAcceptsWarehouse03DenseLanes()
{
DenseLaneOutboundQuery query = new DenseLaneOutboundQuery();
query.setWarehouseId("02");
assertThatThrownBy(() -> service.list(query)).hasMessageContaining("03仓");
}
@Test
void listReturnsFullLaneTotalsAndCurrentOuterTray()
{
DenseLaneOutboundQuery query = new DenseLaneOutboundQuery();
query.setWarehouseId("03");
DenseLaneSummary summary = new DenseLaneSummary();
summary.setGroupCoding("G-01");
summary.setFullTrayCount(3);
summary.setAvailableTrayCount(2);
summary.setTotalQuantity(new BigDecimal("40"));
summary.setOutermostTrayCode("A001");
when(denseLaneMapper.selectLaneSummaries(query)).thenReturn(List.of(summary));
assertThat(service.list(query)).containsExactly(summary);
}
@Test
void previewSelectsExactlyOutermostNUnreservedTrays()
{
DenseLaneOutboundRequest request = request(2);
List<DenseLanePosition> positions = List.of(position("CS-2", 2L, "A002"),
position("CS-3", 3L, "A003"), position("CS-1", 1L, "A001"));
when(relocationJobMapper.selectRelocationSourcesForUpdate(any())).thenReturn(positions);
when(denseLaneService.orderOutermostFirst(positions))
.thenReturn(List.of(positions.get(1), positions.get(0), positions.get(2)));
when(relocationJobMapper.selectStockDetailsForUpdate("CS-3", "A003"))
.thenReturn(List.of(detail("STOCK-3", "D-3", "P-3", "15")));
when(relocationJobMapper.selectStockDetailsForUpdate("CS-2", "A002"))
.thenReturn(List.of(detail("STOCK-2", "D-2", "P-2", "10")));
DenseLaneOutboundPreview preview = service.preview(request);
assertThat(preview.getTrays()).extracting(DenseLanePosition::getTrayCode)
.containsExactly("A003", "A002");
assertThat(preview.getTotalQuantity()).isEqualByComparingTo("25");
assertThat(preview.getPreviewToken()).isNotBlank();
}
@Test
void previewIncludesCompleteInventoryDetailsForEverySelectedTray()
{
DenseLaneOutboundRequest request = request(1);
DenseLanePosition position = position("CS-1", 1L, "A001");
List<MydoJobDetail> details = List.of(
detail("STOCK-1", "D-1", "P-1", "7"),
detail("STOCK-1", "D-2", "P-2", "5"));
when(relocationJobMapper.selectRelocationSourcesForUpdate(any())).thenReturn(List.of(position));
when(denseLaneService.orderOutermostFirst(any())).thenReturn(List.of(position));
when(relocationJobMapper.selectStockDetailsForUpdate("CS-1", "A001")).thenReturn(details);
DenseLaneOutboundPreview preview = service.preview(request);
assertThat(preview.getTrays()).singleElement().satisfies(tray ->
assertThat(tray.getStockDetails()).containsExactlyElementsOf(details));
}
@Test
void previewTotalUsesOnlySelectedCargoSpaceInventoryDetails()
{
DenseLaneOutboundRequest request = request(1);
DenseLanePosition position = position("CS-1", 1L, "DUPLICATE-TRAY");
when(relocationJobMapper.selectRelocationSourcesForUpdate(any())).thenReturn(List.of(position));
when(denseLaneService.orderOutermostFirst(any())).thenReturn(List.of(position));
when(relocationJobMapper.selectStockDetailsForUpdate("CS-1", "DUPLICATE-TRAY"))
.thenReturn(List.of(detail("STOCK-1", "D-1", "P-1", "7"),
detail("STOCK-1", "D-2", "P-2", "5")));
DenseLaneOutboundPreview preview = service.preview(request);
assertThat(preview.getTotalQuantity()).isEqualByComparingTo("12");
}
@Test
void previewRejectsQuantityOverAvailableCapacity()
{
DenseLaneOutboundRequest request = request(2);
when(relocationJobMapper.selectRelocationSourcesForUpdate(any()))
.thenReturn(List.of(position("CS-1", 1L, "A001")));
assertThatThrownBy(() -> service.preview(request)).hasMessageContaining("可出库托盘数量不足");
}
@Test
void previewTokenChangesWhenSelectedTrayInventoryChanges()
{
DenseLaneOutboundRequest request = request(1);
DenseLanePosition position = position("CS-1", 1L, "A001");
when(relocationJobMapper.selectRelocationSourcesForUpdate(any())).thenReturn(List.of(position));
when(denseLaneService.orderOutermostFirst(any())).thenReturn(List.of(position));
when(relocationJobMapper.selectStockDetailsForUpdate("CS-1", "A001"))
.thenReturn(List.of(detail("STOCK-1", "D-1", "P-1", "10")))
.thenReturn(List.of(detail("STOCK-1", "D-1", "P-1", "9")));
String firstToken = service.preview(request).getPreviewToken();
String changedToken = service.preview(request).getPreviewToken();
assertThat(changedToken).isNotEqualTo(firstToken);
}
@Test
void generationRejectsStalePreview()
{
DenseLaneOutboundRequest request = request(1);
DenseLanePosition first = position("CS-1", 1L, "A001");
when(relocationJobMapper.selectRelocationSourcesForUpdate(any())).thenReturn(List.of(first));
when(denseLaneService.orderOutermostFirst(any())).thenAnswer(invocation -> invocation.getArgument(0));
request.setPreviewToken(service.preview(request).getPreviewToken());
DenseLanePosition changed = position("CS-2", 2L, "A002");
when(relocationJobMapper.selectRelocationSourcesForUpdate(any())).thenReturn(List.of(changed));
assertThatThrownBy(() -> service.generate(request)).hasMessageContaining("预览已失效");
}
@Test
void generationValidatesExitAndCreatesCompleteType20Jobs()
{
DenseLaneOutboundRequest request = request(1);
DenseLanePosition position = position("CS-1", 1L, "A001");
when(relocationJobMapper.selectRelocationSourcesForUpdate(any())).thenReturn(List.of(position));
when(denseLaneService.orderOutermostFirst(any())).thenAnswer(invocation -> invocation.getArgument(0));
List<MydoJobDetail> stockDetails = List.of(detail("STOCK-1", "D-1", "P-1", "7"),
detail("STOCK-1", "D-2", "P-2", "5"));
when(relocationJobMapper.selectStockDetailsForUpdate("CS-1", "A001"))
.thenReturn(stockDetails);
request.setPreviewToken(service.preview(request).getPreviewToken());
BasStationPoint exit = new BasStationPoint();
exit.setPointId("OUT-1");
exit.setWarehouseId("03");
exit.setPointType("2");
exit.setStatus("0");
exit.setPointStatus("0");
exit.setTrayType("A,B");
when(stationPointService.selectBasStationPointByPointId("OUT-1")).thenReturn(exit);
when(jobIdService.getId("CK")).thenReturn("OUT-JOB-1");
when(taskNoService.getTaskNo("20")).thenReturn("TASK-20-1");
when(mydoJobService.insertMydoJob(any(MydoJob.class))).thenReturn(1);
when(mydoJobDetailService.insertMydoJobDetail(any(MydoJobDetail.class))).thenReturn(1);
when(locationOutboundMapper.reserveStockDetailForOutbound(any())).thenReturn(1);
when(cargoSpaceMapper.markOutboundAssignedByCargoSpaceId("CS-1")).thenReturn(1);
when(dispatchService.dispatch("OUT-JOB-1", DispatchTrigger.INITIAL))
.thenReturn(AjaxResult.success());
List<String> jobIds = service.generate(request);
assertThat(jobIds).containsExactly("OUT-JOB-1");
ArgumentCaptor<MydoJob> jobCaptor = ArgumentCaptor.forClass(MydoJob.class);
verify(mydoJobService).insertMydoJob(jobCaptor.capture());
assertThat(jobCaptor.getValue().getInOrOut()).isEqualTo(20L);
assertThat(jobCaptor.getValue().getTrayCode()).isEqualTo("A001");
verify(mydoJobDetailService, org.mockito.Mockito.times(2))
.insertMydoJobDetail(any(MydoJobDetail.class));
verify(dispatchService).dispatch("OUT-JOB-1", DispatchTrigger.INITIAL);
}
private DenseLaneOutboundRequest request(int count)
{
DenseLaneOutboundRequest request = new DenseLaneOutboundRequest();
request.setWarehouseId("03");
request.setTheFloor(1L);
request.setGroupCoding("G-01");
request.setTrayCount(count);
request.setExitId("OUT-1");
return request;
}
private DenseLanePosition position(String cargoSpaceId, Long column, String trayCode)
{
DenseLanePosition position = new DenseLanePosition();
position.setCargoSpaceId(cargoSpaceId);
position.setCargoSpaceName(cargoSpaceId);
position.setWarehouseId("03");
position.setGroupCoding("G-01");
position.setTheFloor(1L);
position.setTheColumn(column);
position.setTrayCode(trayCode);
position.setStockQuantity(BigDecimal.TEN);
return position;
}
private MydoJobDetail detail(String stockId, String detailId, String productId, String quantity)
{
MydoJobDetail detail = new MydoJobDetail();
detail.setStockId(stockId);
detail.setStockinDetailId(detailId);
detail.setProductId(productId);
detail.setProductName(productId);
detail.setStockQuantity(new BigDecimal(quantity));
return detail;
}
}

View File

@@ -0,0 +1,116 @@
package com.ruoyi.out.dense.service.impl;
import java.util.List;
import com.ruoyi.in.job.domain.MydoJob;
import com.ruoyi.out.dense.domain.DenseLanePosition;
import com.ruoyi.out.dense.mapper.DenseLaneMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
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 DenseLaneServiceImplTest
{
private DenseLaneMapper mapper;
private DenseLaneServiceImpl service;
@BeforeEach
void setUp()
{
mapper = mock(DenseLaneMapper.class);
service = new DenseLaneServiceImpl(mapper);
}
@Test
void directionOneUsesMaximumOccupiedColumnAsOutermost()
{
DenseLanePosition source = position("CS-8", 8L, "1", 1L, 10L);
DenseLanePosition outermost = position("CS-10", 10L, "1", 1L, 10L);
when(mapper.selectPosition("CS-8")).thenReturn(source);
when(mapper.selectPosition("CS-10")).thenReturn(outermost);
when(mapper.selectOccupiedLanePositions(source)).thenReturn(List.of(
position("CS-2", 2L, "1", 1L, 10L),
source,
outermost));
when(mapper.selectOccupiedLanePositions(outermost)).thenReturn(List.of(
position("CS-2", 2L, "1", 1L, 10L),
source,
outermost));
assertThat(service.checkDispatchEligibility(job("03", "CS-8")).isEligible()).isFalse();
assertThat(service.checkDispatchEligibility(job("03", "CS-10")).isEligible()).isTrue();
}
@Test
void directionTwoUsesMinimumOccupiedColumnAsOutermost()
{
DenseLanePosition source = position("CS-2", 2L, "2", 1L, 10L);
when(mapper.selectPosition("CS-2")).thenReturn(source);
when(mapper.selectOccupiedLanePositions(source)).thenReturn(List.of(
position("CS-2", 2L, "2", 1L, 10L),
position("CS-7", 7L, "2", 1L, 10L)));
assertThat(service.checkDispatchEligibility(job("03", "CS-2")).isEligible()).isTrue();
}
@Test
void directionThreeUsesNearestBoundaryThenSmallerColumn()
{
List<DenseLanePosition> ordered = service.orderOutermostFirst(List.of(
position("CS-6", 6L, "3", 1L, 10L),
position("CS-10", 10L, "3", 1L, 10L),
position("CS-1", 1L, "3", 1L, 10L),
position("CS-5", 5L, "3", 1L, 10L)));
assertThat(ordered).extracting(DenseLanePosition::getCargoSpaceId)
.containsExactly("CS-1", "CS-10", "CS-5", "CS-6");
}
@Test
void nonWarehouse03BypassesDenseLookup()
{
assertThat(service.checkDispatchEligibility(job("02", "CS-1")).isEligible()).isTrue();
verify(mapper, never()).selectPosition("CS-1");
}
@Test
void blockerQueryReturnsOnlyUnassignedOuterTrays()
{
List<DenseLanePosition> blockers = List.of(
position("CS-9", 9L, "1", 1L, 10L),
position("CS-8", 8L, "1", 1L, 10L));
when(mapper.selectBlockersForUpdate("CS-7")).thenReturn(blockers);
assertThat(service.selectBlockersForUpdate("CS-7"))
.extracting(DenseLanePosition::getCargoSpaceId)
.containsExactly("CS-9", "CS-8");
}
private MydoJob job(String warehouseId, String cargoSpaceId)
{
MydoJob job = new MydoJob();
job.setWarehouseId(warehouseId);
job.setCargoSpaceId(cargoSpaceId);
return job;
}
private DenseLanePosition position(String cargoSpaceId, Long column, String direction,
Long minColumn, Long maxColumn)
{
DenseLanePosition position = new DenseLanePosition();
position.setCargoSpaceId(cargoSpaceId);
position.setWarehouseId("03");
position.setGroupCoding("G-01-01");
position.setTheFloor(1L);
position.setTheColumn(column);
position.setDirectionType(direction);
position.setMinColumn(minColumn);
position.setMaxColumn(maxColumn);
position.setTrayCode("T-" + cargoSpaceId);
return position;
}
}

View File

@@ -0,0 +1,82 @@
package com.ruoyi.out.dispatch.mapper;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
class WcsJobDispatchMapperSqlTest
{
@Test
void dispatchMapperDefinesDurableRetryStateTransitions() throws Exception
{
String xml = Files.readString(resolvePath(
"src/main/resources/mapper/out/WcsJobDispatchMapper.xml",
"ruoyi-admin/src/main/resources/mapper/out/WcsJobDispatchMapper.xml"));
assertThat(xml)
.contains("PENDING")
.contains("SENDING")
.contains("BLOCKED")
.contains("RETRY_WAIT")
.contains("SUCCESS")
.contains("EXHAUSTED")
.contains("auto_retry_count &lt; 5")
.contains("dispatch_status = 'SENDING'")
.contains("last_attempt_time &lt;= dateadd(minute, -5")
.contains("version_no = version_no + 1")
.contains("selectRetryCandidates")
.contains("claimForSend")
.contains("markRetryWait")
.contains("markExhausted");
}
@Test
void migrationCreatesRetryTaskRelocationTypeAndDenseLaneMenu() throws Exception
{
String sql = Files.readString(resolvePath(
"../sql/20260716_dense_outbound_relocation_dispatch.sql",
"sql/20260716_dense_outbound_relocation_dispatch.sql"));
assertThat(sql)
.contains("CREATE TABLE dbo.wcs_job_dispatch")
.contains("IF NOT EXISTS")
.contains("auto_retry_count")
.contains("manual_retry_count")
.contains("last_error")
.contains("wcsOutboundRetryTask.retryFailedJobs")
.contains("0 0/1 * * * ?")
.contains("out:denseLaneOutbound:list")
.contains("out:denseLaneOutbound:preview")
.contains("out:denseLaneOutbound:generate")
.contains("out:job:pushWcs")
.contains("N'23'")
.contains("N'移库'");
}
@Test
void outboundJobQueriesExposeDispatchState() throws Exception
{
String xml = Files.readString(resolvePath(
"src/main/resources/mapper/in/MydoJobMapper.xml",
"ruoyi-admin/src/main/resources/mapper/in/MydoJobMapper.xml"));
assertThat(xml)
.contains("left join wcs_job_dispatch")
.contains("dispatchStatus")
.contains("autoRetryCount")
.contains("manualRetryCount")
.contains("lastDispatchError");
}
private Path resolvePath(String modulePath, String rootPath)
{
return Stream.of(Path.of(modulePath), Path.of(rootPath))
.filter(Files::exists)
.findFirst()
.orElseThrow();
}
}

View File

@@ -0,0 +1,321 @@
package com.ruoyi.out.dispatch.service.impl;
import java.util.List;
import java.util.Map;
import com.ruoyi.api.client.WcsClient;
import com.ruoyi.api.domain.ApiResponse;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.in.job.domain.MydoJob;
import com.ruoyi.in.job.mapper.MydoJobMapper;
import com.ruoyi.out.dense.domain.DispatchEligibility;
import com.ruoyi.out.dense.service.IDenseLaneService;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
import com.ruoyi.out.dispatch.domain.WcsJobDispatch;
import com.ruoyi.out.dispatch.mapper.WcsJobDispatchMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.mock;
class OutboundDispatchServiceImplTest
{
private MydoJobMapper jobMapper;
private WcsJobDispatchMapper dispatchMapper;
private IDenseLaneService denseLaneService;
private WcsClient wcsClient;
private OutboundDispatchServiceImpl service;
@BeforeEach
void setUp()
{
jobMapper = mock(MydoJobMapper.class);
dispatchMapper = mock(WcsJobDispatchMapper.class);
denseLaneService = mock(IDenseLaneService.class);
wcsClient = mock(WcsClient.class);
service = new OutboundDispatchServiceImpl(jobMapper, dispatchMapper, denseLaneService, wcsClient);
}
@Test
void initialFailureKeepsBusinessSuccessAndStartsRetryCountAtZero()
{
MydoJob job = jobOfType("CK1", 21L);
stubClaim(job, dispatch("CK1", WcsJobDispatch.PENDING, 0));
when(wcsClient.sendRetrieveTask(job)).thenReturn(ApiResponse.fail("WCS_ERROR", "连接失败"));
AjaxResult result = service.dispatch("CK1", DispatchTrigger.INITIAL);
assertThat(result.isSuccess()).isTrue();
verify(dispatchMapper).markRetryWait(argThat(params ->
"CK1".equals(params.get("jobId"))
&& Integer.valueOf(0).equals(params.get("autoRetryCount"))
&& String.valueOf(params.get("lastError")).contains("连接失败")));
verify(jobMapper, never()).updateMydoJob(any(MydoJob.class));
}
@Test
void fifthAutomaticRetryFailureBecomesExhausted()
{
MydoJob job = jobOfType("CK2", 21L);
stubClaim(job, dispatch("CK2", WcsJobDispatch.RETRY_WAIT, 4));
when(wcsClient.sendRetrieveTask(job)).thenReturn(ApiResponse.fail("WCS_ERROR", "仍然失败"));
AjaxResult result = service.dispatch("CK2", DispatchTrigger.AUTO_RETRY);
assertThat(result.isSuccess()).isTrue();
verify(dispatchMapper).claimForSend(argThat(params ->
DispatchTrigger.AUTO_RETRY.name().equals(params.get("trigger"))));
verify(dispatchMapper).markExhausted(argThat(params ->
Integer.valueOf(5).equals(params.get("autoRetryCount"))));
verify(dispatchMapper, never()).markRetryWait(any(Map.class));
}
@Test
void automaticRetryBelowLimitReturnsToRetryWait()
{
MydoJob job = jobOfType("CK3", 21L);
stubClaim(job, dispatch("CK3", WcsJobDispatch.RETRY_WAIT, 3));
when(wcsClient.sendRetrieveTask(job)).thenReturn(ApiResponse.fail("WCS_ERROR", "暂时失败"));
service.dispatch("CK3", DispatchTrigger.AUTO_RETRY);
verify(dispatchMapper).markRetryWait(argThat(params ->
Integer.valueOf(4).equals(params.get("autoRetryCount"))));
verify(dispatchMapper, never()).markExhausted(any(Map.class));
}
@Test
void staleSendingRecordAtRetryLimitIsMarkedExhaustedWithoutCallingWcs()
{
MydoJob job = jobOfType("CK-STALE", 21L);
WcsJobDispatch dispatch = dispatch("CK-STALE", WcsJobDispatch.SENDING, 5);
dispatch.setLastAttemptTime(new java.util.Date(System.currentTimeMillis() - 6 * 60_000L));
when(jobMapper.selectMydoJobByJobId("CK-STALE")).thenReturn(job);
when(dispatchMapper.selectByJobId("CK-STALE")).thenReturn(dispatch);
AjaxResult result = service.dispatch("CK-STALE", DispatchTrigger.AUTO_RETRY);
assertThat(result.isSuccess()).isTrue();
verify(dispatchMapper).markExhausted(argThat(params ->
Integer.valueOf(5).equals(params.get("autoRetryCount"))));
verify(wcsClient, never()).sendRetrieveTask(any(MydoJob.class));
}
@Test
void manualDispatchCanRecoverAnExhaustedJob()
{
MydoJob job = jobOfType("CK4", 21L);
stubClaim(job, dispatch("CK4", WcsJobDispatch.EXHAUSTED, 5));
when(wcsClient.sendRetrieveTask(job)).thenReturn(ApiResponse.ok("OK"));
when(jobMapper.updateMydoJob(any(MydoJob.class))).thenReturn(1);
AjaxResult result = service.dispatch("CK4", DispatchTrigger.MANUAL);
assertThat(result.isSuccess()).isTrue();
verify(dispatchMapper).claimForSend(argThat(params ->
DispatchTrigger.MANUAL.name().equals(params.get("trigger"))));
verify(dispatchMapper).markSuccess(any(Map.class));
}
@Test
void successfulDispatchUpdatesBusinessJobOnlyAfterWcsAccepts()
{
MydoJob job = jobOfType("CK5", 21L);
stubClaim(job, dispatch("CK5", WcsJobDispatch.PENDING, 0));
when(wcsClient.sendRetrieveTask(job)).thenReturn(ApiResponse.ok("WCS-OK"));
when(jobMapper.updateMydoJob(any(MydoJob.class))).thenReturn(1);
AjaxResult result = service.dispatch("CK5", DispatchTrigger.INITIAL);
assertThat(result.isSuccess()).isTrue();
ArgumentCaptor<MydoJob> updateCaptor = ArgumentCaptor.forClass(MydoJob.class);
verify(jobMapper).updateMydoJob(updateCaptor.capture());
assertThat(updateCaptor.getValue().getJobId()).isEqualTo("CK5");
assertThat(updateCaptor.getValue().getStatus()).isEqualTo("3");
assertThat(updateCaptor.getValue().getExecutionPhase()).isEqualTo("1");
assertThat(updateCaptor.getValue().getStartTime()).isNotBlank();
verify(dispatchMapper).markSuccess(any(Map.class));
}
@Test
void rejectedAtomicClaimDoesNotCallWcs()
{
MydoJob job = jobOfType("CK6", 21L);
when(jobMapper.selectMydoJobByJobId("CK6")).thenReturn(job);
when(dispatchMapper.selectByJobId("CK6"))
.thenReturn(dispatch("CK6", WcsJobDispatch.RETRY_WAIT, 1));
when(denseLaneService.checkDispatchEligibility(job)).thenReturn(DispatchEligibility.allowed());
when(dispatchMapper.claimForSend(any(Map.class))).thenReturn(0);
AjaxResult result = service.dispatch("CK6", DispatchTrigger.AUTO_RETRY);
assertThat(result.isSuccess()).isTrue();
verify(wcsClient, never()).sendRetrieveTask(any(MydoJob.class));
}
@Test
void activeSendingRecordCannotBeClaimedAgain()
{
MydoJob job = jobOfType("CK-SENDING", 21L);
WcsJobDispatch dispatch = dispatch("CK-SENDING", WcsJobDispatch.SENDING, 1);
dispatch.setLastAttemptTime(new java.util.Date());
when(jobMapper.selectMydoJobByJobId("CK-SENDING")).thenReturn(job);
when(dispatchMapper.selectByJobId("CK-SENDING")).thenReturn(dispatch);
when(denseLaneService.checkDispatchEligibility(job)).thenReturn(DispatchEligibility.allowed());
when(dispatchMapper.claimForSend(any(Map.class))).thenReturn(1);
AjaxResult result = service.dispatch("CK-SENDING", DispatchTrigger.MANUAL);
assertThat(result.isSuccess()).isTrue();
verify(dispatchMapper, never()).claimForSend(any(Map.class));
verify(wcsClient, never()).sendRetrieveTask(any(MydoJob.class));
}
@Test
void completedJobCannotBeManuallyDispatched()
{
MydoJob job = jobOfType("CK-COMPLETED", 21L);
job.setStatus("4");
when(jobMapper.selectMydoJobByJobId("CK-COMPLETED")).thenReturn(job);
AjaxResult result = service.dispatch("CK-COMPLETED", DispatchTrigger.MANUAL);
assertThat(result.isSuccess()).isFalse();
assertThat(result.get("msg")).asString().contains("状态不允许");
verify(dispatchMapper, never()).insertIfAbsent(any(String.class));
verify(wcsClient, never()).sendRetrieveTask(any(MydoJob.class));
}
@Test
void blockedDenseJobDoesNotConsumeRetryOrCallWcs()
{
MydoJob job = jobOfType("CK7", 21L);
when(jobMapper.selectMydoJobByJobId("CK7")).thenReturn(job);
when(dispatchMapper.selectByJobId("CK7"))
.thenReturn(dispatch("CK7", WcsJobDispatch.RETRY_WAIT, 2));
when(denseLaneService.checkDispatchEligibility(job))
.thenReturn(DispatchEligibility.blocked("前方仍有2个托盘"));
AjaxResult result = service.dispatch("CK7", DispatchTrigger.AUTO_RETRY);
assertThat(result.isSuccess()).isTrue();
verify(dispatchMapper).markBlocked(argThat(params ->
"前方仍有2个托盘".equals(params.get("lastError"))));
verify(dispatchMapper, never()).claimForSend(any(Map.class));
verify(wcsClient, never()).sendRetrieveTask(any(MydoJob.class));
}
@Test
void firstActualSendAfterBlockedStateDoesNotCountAsAutomaticRetry()
{
MydoJob job = jobOfType("CK-BLOCKED-FIRST", 21L);
WcsJobDispatch dispatch = dispatch("CK-BLOCKED-FIRST", WcsJobDispatch.BLOCKED, 0);
stubClaim(job, dispatch);
when(wcsClient.sendRetrieveTask(job)).thenReturn(ApiResponse.fail("WCS_ERROR", "首次调用失败"));
AjaxResult result = service.dispatch("CK-BLOCKED-FIRST", DispatchTrigger.AUTO_RETRY);
assertThat(result.isSuccess()).isTrue();
verify(dispatchMapper).claimForSend(argThat(params ->
DispatchTrigger.INITIAL.name().equals(params.get("trigger"))));
verify(dispatchMapper).markRetryWait(argThat(params ->
Integer.valueOf(0).equals(params.get("autoRetryCount"))));
}
@ParameterizedTest
@ValueSource(longs = {20L, 21L, 23L})
void routesOutboundByJobType(long jobType)
{
String jobId = "CK-TYPE-" + jobType;
MydoJob job = jobOfType(jobId, jobType);
stubClaim(job, dispatch(jobId, WcsJobDispatch.PENDING, 0));
when(jobMapper.updateMydoJob(any(MydoJob.class))).thenReturn(1);
when(wcsClient.sendLocationOutboundTask(job)).thenReturn(ApiResponse.ok("LOCATION-OK"));
when(wcsClient.sendRetrieveTask(job)).thenReturn(ApiResponse.ok("RETRIEVE-OK"));
when(wcsClient.sendRelocationTask(job)).thenReturn(ApiResponse.ok("MOVE-OK"));
service.dispatch(jobId, DispatchTrigger.INITIAL);
if (jobType == 20L) {
verify(wcsClient).sendLocationOutboundTask(job);
verify(wcsClient, never()).sendRetrieveTask(job);
verify(wcsClient, never()).sendRelocationTask(job);
} else if (jobType == 23L) {
verify(wcsClient).sendRelocationTask(job);
verify(wcsClient, never()).sendRetrieveTask(job);
verify(wcsClient, never()).sendLocationOutboundTask(job);
} else {
verify(wcsClient).sendRetrieveTask(job);
verify(wcsClient, never()).sendLocationOutboundTask(job);
verify(wcsClient, never()).sendRelocationTask(job);
}
reset(wcsClient);
}
@Test
void retryTaskDispatchesEveryCandidateAsAutomaticRetry()
{
WcsJobDispatch first = dispatch("CK8", WcsJobDispatch.RETRY_WAIT, 1);
WcsJobDispatch second = dispatch("CK9", WcsJobDispatch.BLOCKED, 0);
when(dispatchMapper.selectRetryCandidates(any(), any(Integer.class)))
.thenReturn(List.of(first, second));
MydoJob firstJob = jobOfType("CK8", 21L);
MydoJob secondJob = jobOfType("CK9", 21L);
when(jobMapper.selectMydoJobByJobId("CK8")).thenReturn(firstJob);
when(jobMapper.selectMydoJobByJobId("CK9")).thenReturn(secondJob);
when(dispatchMapper.selectByJobId("CK8")).thenReturn(first);
when(dispatchMapper.selectByJobId("CK9")).thenReturn(second);
when(denseLaneService.checkDispatchEligibility(any(MydoJob.class)))
.thenReturn(DispatchEligibility.allowed());
when(dispatchMapper.claimForSend(any(Map.class))).thenReturn(0);
int processed = service.retryFailedJobs();
assertThat(processed).isEqualTo(2);
verify(dispatchMapper).claimForSend(argThat(params -> "CK8".equals(params.get("jobId"))));
verify(dispatchMapper).claimForSend(argThat(params -> "CK9".equals(params.get("jobId"))));
}
private void stubClaim(MydoJob job, WcsJobDispatch dispatch)
{
when(jobMapper.selectMydoJobByJobId(job.getJobId())).thenReturn(job);
when(dispatchMapper.selectByJobId(job.getJobId())).thenReturn(dispatch);
when(denseLaneService.checkDispatchEligibility(job)).thenReturn(DispatchEligibility.allowed());
when(dispatchMapper.claimForSend(any(Map.class))).thenReturn(1);
}
private MydoJob jobOfType(String jobId, Long inOrOut)
{
MydoJob job = new MydoJob();
job.setJobId(jobId);
job.setInOrOut(inOrOut);
job.setStatus("1");
job.setExecutionPhase("0");
job.setWarehouseId("03");
job.setCargoSpaceId("CS-" + jobId);
return job;
}
private WcsJobDispatch dispatch(String jobId, String status, int autoRetryCount)
{
WcsJobDispatch dispatch = new WcsJobDispatch();
dispatch.setJobId(jobId);
dispatch.setDispatchStatus(status);
dispatch.setAutoRetryCount(autoRetryCount);
dispatch.setManualRetryCount(0);
dispatch.setVersionNo(0);
if (!WcsJobDispatch.PENDING.equals(status) && !WcsJobDispatch.BLOCKED.equals(status)) {
dispatch.setLastAttemptTime(new java.util.Date());
}
return dispatch;
}
}

View File

@@ -0,0 +1,62 @@
package com.ruoyi.out.job.controller;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
import com.ruoyi.out.dispatch.service.IOutboundDispatchService;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class OutMydoJobDispatchControllerTest
{
@Test
void manualEndpointUsesDedicatedPermissionAndManualTrigger() throws Exception
{
Method endpoint = OutMydoJobController.class.getMethod("pushWcs", String.class);
assertThat(endpoint.getAnnotation(PreAuthorize.class).value())
.isEqualTo("@ss.hasPermi('out:job:pushWcs')");
IOutboundDispatchService dispatchService = mock(IOutboundDispatchService.class);
when(dispatchService.dispatch("CK-1", DispatchTrigger.MANUAL))
.thenReturn(AjaxResult.success("已下发"));
OutMydoJobController controller = new OutMydoJobController();
ReflectionTestUtils.setField(controller, "outboundDispatchService", dispatchService);
assertThat(controller.pushWcs("CK-1").isSuccess()).isTrue();
verify(dispatchService).dispatch("CK-1", DispatchTrigger.MANUAL);
}
@Test
void jobPageShowsDispatchStateAndRestrictsManualAction() throws Exception
{
String vue = Files.readString(resolve("ruoyi-ui/src/views/out/job/index.vue"));
String api = Files.readString(resolve("ruoyi-ui/src/api/out/job.js"));
assertThat(vue)
.contains("prop=\"dispatchStatus\"")
.contains("prop=\"autoRetryCount\"")
.contains("lastDispatchError")
.contains("out:job:pushWcs")
.contains("canPushWcs")
.contains("手动下发 WCS")
.doesNotContain("v-hasPermi=\"['out:job:edit']\"");
assertThat(api).contains("/out/job/pushWcs/");
}
private Path resolve(String path)
{
return Stream.of(Path.of(path), Path.of("..", path))
.filter(Files::exists)
.findFirst()
.orElseThrow();
}
}

View File

@@ -15,6 +15,9 @@ import com.ruoyi.in.jobdetail.service.IMydoJobDetailService;
import com.ruoyi.out.location.domain.LocationOutboundGenerateRequest;
import com.ruoyi.out.location.domain.LocationOutboundStock;
import com.ruoyi.out.location.mapper.LocationOutboundMapper;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
import com.ruoyi.out.dispatch.service.IOutboundDispatchService;
import com.ruoyi.out.relocation.service.IRelocationJobService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@@ -59,6 +62,12 @@ class LocationOutboundServiceImplTest {
@Mock
private TaskNoService taskNoService;
@Mock
private IRelocationJobService relocationJobService;
@Mock
private IOutboundDispatchService dispatchService;
@InjectMocks
private LocationOutboundServiceImpl service;
@@ -98,14 +107,16 @@ class LocationOutboundServiceImplTest {
when(locationOutboundMapper.reserveStockDetailForOutbound(any(HashMap.class))).thenReturn(1);
when(basStockMapper.refreshBasStockSummary("KC001")).thenReturn(1);
when(basCargoSpaceMapper.markOutboundAssignedByStockId("KC001")).thenReturn(1);
when(mydoJobService.pushOutJobToWcs("CK001")).thenReturn(AjaxResult.success("WCS出库作业下发成功:OK"));
when(relocationJobService.createBlockerRelocations("CK001")).thenReturn(List.of("MOVE-1"));
when(dispatchService.dispatch("CK001", DispatchTrigger.INITIAL))
.thenReturn(AjaxResult.success("作业暂未下发WCS:等待移库"));
LocationOutboundGenerateRequest request = new LocationOutboundGenerateRequest();
request.setStockId("KC001");
request.setExitId("OUT01");
String result = service.generateWholeTrayOutboundJob(request);
assertThat(result).contains("CK001").contains("WCS出库作业下发成功");
assertThat(result).contains("CK001").contains("等待移库");
ArgumentCaptor<MydoJob> jobCaptor = ArgumentCaptor.forClass(MydoJob.class);
verify(mydoJobService).insertMydoJob(jobCaptor.capture());
@@ -132,7 +143,9 @@ class LocationOutboundServiceImplTest {
.containsExactly("SD001", "SD002");
verify(basStockMapper).refreshBasStockSummary("KC001");
verify(basCargoSpaceMapper).markOutboundAssignedByStockId("KC001");
verify(mydoJobService).pushOutJobToWcs("CK001");
verify(relocationJobService).createBlockerRelocations("CK001");
verify(dispatchService).dispatch("CK001", DispatchTrigger.INITIAL);
verify(mydoJobService, never()).pushOutJobToWcs(any());
}
@Test
@@ -157,7 +170,9 @@ class LocationOutboundServiceImplTest {
when(taskNoService.getTaskNo("20")).thenReturn("50002");
when(mydoJobService.insertMydoJob(any(MydoJob.class))).thenReturn(1);
when(basCargoSpaceMapper.markOutboundAssignedByCargoSpaceId("CS-EMPTY")).thenReturn(1);
when(mydoJobService.pushOutJobToWcs("CK002")).thenReturn(AjaxResult.success("WCS出库作业下发成功:OK"));
when(relocationJobService.createBlockerRelocations("CK002")).thenReturn(List.of());
when(dispatchService.dispatch("CK002", DispatchTrigger.INITIAL))
.thenReturn(AjaxResult.success("WCS出库作业下发成功:OK"));
LocationOutboundGenerateRequest request = new LocationOutboundGenerateRequest();
request.setCargoSpaceId("CS-EMPTY");
@@ -181,7 +196,9 @@ class LocationOutboundServiceImplTest {
verify(locationOutboundMapper, never()).reserveStockDetailForOutbound(any(HashMap.class));
verify(basStockMapper, never()).refreshBasStockSummary(any());
verify(basCargoSpaceMapper).markOutboundAssignedByCargoSpaceId("CS-EMPTY");
verify(mydoJobService).pushOutJobToWcs("CK002");
verify(relocationJobService).createBlockerRelocations("CK002");
verify(dispatchService).dispatch("CK002", DispatchTrigger.INITIAL);
verify(mydoJobService, never()).pushOutJobToWcs(any());
}
private BasStockDetail stockDetail(String detailId, String productId, String productName, String lotNo, String availableQuantity) {

View File

@@ -0,0 +1,66 @@
package com.ruoyi.out.relocation.mapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class RelocationJobMapperSqlTest
{
@Test
void relocationRuntimeQueriesUseExplicitSnakeCaseResultMaps() throws Exception
{
String xml = Files.readString(resolveMapperXml());
String positionMap = section(xml, "<resultMap id=\"DenseLanePositionResult\"", "</resultMap>");
String stockMap = section(xml, "<resultMap id=\"StockDetailResult\"", "</resultMap>");
String jobSelect = section(xml, "<select id=\"selectJobForUpdate\"", "</select>");
String stockSelect = section(xml, "<select id=\"selectStockDetailsForUpdate\"", "</select>");
String sourceSelect = section(xml, "<select id=\"selectRelocationSourcesForUpdate\"", "</select>");
assertThat(positionMap)
.contains("property=\"cargoSpaceId\" column=\"cargo_space_id\"")
.contains("property=\"stockQuantity\" column=\"stock_quantity\"");
assertThat(stockMap)
.contains("property=\"stockId\" column=\"stock_id\"")
.contains("property=\"stockinDetailId\" column=\"stockin_detail_id\"")
.contains("property=\"projectNo\" column=\"project_no\"");
assertThat(jobSelect)
.contains("resultMap=\"com.ruoyi.in.job.mapper.MydoJobMapper.MydoJobResult\"");
assertThat(stockSelect).contains("resultMap=\"StockDetailResult\"");
assertThat(sourceSelect).contains("resultMap=\"DenseLanePositionResult\"");
}
@Test
void stockDetailQueryUsesActualBasStockSupplierColumns() throws Exception
{
String xml = Files.readString(resolveMapperXml());
String stockSelect = section(xml, "<select id=\"selectStockDetailsForUpdate\"", "</select>");
assertThat(stockSelect)
.contains("stock.supplier_id as supplier_id")
.contains("stock.supplier_name as supplier_name")
.doesNotContain("stock.suplier_id")
.doesNotContain("stock.suplier_name");
}
private Path resolveMapperXml()
{
return Stream.of(
Path.of("src/main/resources/mapper/out/RelocationJobMapper.xml"),
Path.of("ruoyi-admin/src/main/resources/mapper/out/RelocationJobMapper.xml"))
.filter(Files::exists)
.findFirst()
.orElseThrow();
}
private String section(String text, String startMarker, String endMarker)
{
int start = text.indexOf(startMarker);
assertThat(start).isGreaterThanOrEqualTo(0);
int end = text.indexOf(endMarker, start);
assertThat(end).isGreaterThan(start);
return text.substring(start, end);
}
}

View File

@@ -0,0 +1,230 @@
package com.ruoyi.out.relocation.service.impl;
import java.math.BigDecimal;
import java.util.List;
import com.ruoyi.bas.cargospace.domain.BasCargoSpace;
import com.ruoyi.bas.id.job.service.JobIdService;
import com.ruoyi.bas.id.taskno.service.TaskNoService;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.in.job.domain.MydoJob;
import com.ruoyi.in.jobdetail.domain.MydoJobDetail;
import com.ruoyi.out.dense.domain.DenseLanePosition;
import com.ruoyi.out.dense.domain.RelocationTargetQuery;
import com.ruoyi.out.dense.service.IDenseLaneService;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
import com.ruoyi.out.dispatch.service.IOutboundDispatchService;
import com.ruoyi.out.relocation.domain.RelocationGenerateRequest;
import com.ruoyi.out.relocation.mapper.RelocationJobMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.inOrder;
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 RelocationJobServiceImplTest
{
private RelocationJobMapper mapper;
private IDenseLaneService denseLaneService;
private JobIdService jobIdService;
private TaskNoService taskNoService;
private IOutboundDispatchService dispatchService;
private RelocationJobServiceImpl service;
@BeforeEach
void setUp()
{
mapper = mock(RelocationJobMapper.class);
denseLaneService = mock(IDenseLaneService.class);
jobIdService = mock(JobIdService.class);
taskNoService = mock(TaskNoService.class);
dispatchService = mock(IOutboundDispatchService.class);
service = new RelocationJobServiceImpl(
mapper, denseLaneService, jobIdService, taskNoService, dispatchService);
}
@Test
void createsOneType23JobPerBlockerAndCopiesEveryStockDetail()
{
MydoJob blocked = blockedJob("OUT-1", "CS-3");
DenseLanePosition outer = source("CS-5", "T-5");
DenseLanePosition inner = source("CS-4", "T-4");
when(mapper.selectJobForUpdate("OUT-1")).thenReturn(blocked);
when(denseLaneService.selectBlockersForUpdate("CS-3")).thenReturn(List.of(outer, inner));
when(denseLaneService.selectRelocationTargetForUpdate(any(RelocationTargetQuery.class)))
.thenReturn(target("TG-1", "TARGET-1"), target("TG-2", "TARGET-2"));
when(mapper.reserveTarget(any())).thenReturn(1);
when(mapper.reserveSource(any())).thenReturn(1);
when(jobIdService.getId("CK")).thenReturn("MOVE-1", "MOVE-2");
when(taskNoService.getTaskNo("23")).thenReturn("MOVE-TASK-1", "MOVE-TASK-2");
when(mapper.selectStockDetailsForUpdate(anyString(), anyString()))
.thenReturn(List.of(detail("S-1", "P-1"), detail("S-2", "P-2")));
when(mapper.insertRelocationJob(any(MydoJob.class))).thenReturn(1);
when(mapper.insertRelocationDetail(any(MydoJobDetail.class))).thenReturn(1);
when(dispatchService.dispatch(anyString(), any(DispatchTrigger.class)))
.thenReturn(AjaxResult.success());
List<String> ids = service.createBlockerRelocations("OUT-1");
assertThat(ids).containsExactly("MOVE-1", "MOVE-2");
ArgumentCaptor<MydoJob> jobs = ArgumentCaptor.forClass(MydoJob.class);
verify(mapper, org.mockito.Mockito.times(2)).insertRelocationJob(jobs.capture());
assertThat(jobs.getAllValues()).allSatisfy(job -> {
assertThat(job.getInOrOut()).isEqualTo(23L);
assertThat(job.getStatus()).isEqualTo("1");
assertThat(job.getExecutionPhase()).isEqualTo("0");
assertThat(job.getTargetCargoSpaceId()).isNotBlank();
});
verify(mapper, org.mockito.Mockito.times(4)).insertRelocationDetail(any(MydoJobDetail.class));
verify(taskNoService, org.mockito.Mockito.times(2)).getTaskNo("23");
verify(dispatchService).dispatch("MOVE-1", DispatchTrigger.INITIAL);
verify(dispatchService).dispatch("MOVE-2", DispatchTrigger.INITIAL);
}
@Test
void reservesAllTargetsBeforeInsertingAnyRelocationJob()
{
MydoJob blocked = blockedJob("OUT-2", "CS-3");
when(mapper.selectJobForUpdate("OUT-2")).thenReturn(blocked);
when(denseLaneService.selectBlockersForUpdate("CS-3"))
.thenReturn(List.of(source("CS-5", "T-5"), source("CS-4", "T-4")));
when(denseLaneService.selectRelocationTargetForUpdate(any(RelocationTargetQuery.class)))
.thenReturn(target("TG-1", "TARGET-1"), (BasCargoSpace) null);
when(mapper.reserveTarget(any())).thenReturn(1);
assertThatThrownBy(() -> service.createBlockerRelocations("OUT-2"))
.hasMessageContaining("同层可用移库货位不足");
verify(mapper, never()).insertRelocationJob(any(MydoJob.class));
}
@Test
void rejectsManualRelocationToSameLaneOrDifferentFloor()
{
RelocationGenerateRequest sameLane = new RelocationGenerateRequest();
sameLane.setWarehouseId("03");
sameLane.setTheFloor(2L);
sameLane.setSourceGroupCoding("G-02");
sameLane.setTargetGroupCoding("G-02");
sameLane.setTrayCount(1);
assertThatThrownBy(() -> service.generate(sameLane))
.hasMessageContaining("不同通道");
}
@Test
void previewTotalUsesExactSelectedCargoSpaceQuantities()
{
RelocationGenerateRequest request = new RelocationGenerateRequest();
request.setWarehouseId("03");
request.setTheFloor(1L);
request.setSourceGroupCoding("SOURCE-GROUP");
request.setTargetGroupCoding("TARGET-GROUP");
request.setTrayCount(2);
DenseLanePosition first = source("CS-1", "DUPLICATE-TRAY");
first.setStockQuantity(new BigDecimal("7"));
DenseLanePosition second = source("CS-2", "UNIQUE-TRAY");
second.setStockQuantity(new BigDecimal("5"));
when(mapper.selectRelocationSourcesForUpdate(request)).thenReturn(List.of(first, second));
when(denseLaneService.orderOutermostFirst(any())).thenReturn(List.of(first, second));
assertThat(service.preview(request).getTotalQuantity()).isEqualByComparingTo("12");
}
@Test
void completionMovesInventorySwapsCargoSpacesArchivesAndWakesNextJob()
{
MydoJob move = blockedJob("MOVE-1", "SOURCE");
move.setInOrOut(23L);
move.setStatus("3");
move.setExecutionPhase("1");
move.setTargetCargoSpaceId("TARGET");
move.setTrayCode("T-1");
when(mapper.selectJobForUpdate("MOVE-1")).thenReturn(move);
when(mapper.lockRelocationState(move)).thenReturn(1);
when(mapper.moveTrayStock(move)).thenReturn(2);
when(mapper.completeCargoSpaces(move)).thenReturn(2);
when(mapper.archiveRelocationDetails("MOVE-1")).thenReturn(2);
when(mapper.archiveRelocationJob("MOVE-1")).thenReturn(1);
when(mapper.deleteRelocationDetails("MOVE-1")).thenReturn(2);
when(mapper.deleteRelocationJob("MOVE-1")).thenReturn(1);
when(mapper.selectNextSourceLaneJob("SOURCE")).thenReturn("NEXT-1");
when(dispatchService.dispatch("NEXT-1", DispatchTrigger.INITIAL))
.thenReturn(AjaxResult.success());
AjaxResult result = service.completeFromWcs("MOVE-1");
assertThat(result.isSuccess()).isTrue();
var ordered = inOrder(mapper);
ordered.verify(mapper).moveTrayStock(move);
ordered.verify(mapper).completeCargoSpaces(move);
ordered.verify(mapper).archiveRelocationDetails("MOVE-1");
ordered.verify(mapper).archiveRelocationJob("MOVE-1");
ordered.verify(mapper).deleteRelocationDetails("MOVE-1");
ordered.verify(mapper).deleteRelocationJob("MOVE-1");
verify(dispatchService).dispatch("NEXT-1", DispatchTrigger.INITIAL);
}
@Test
void duplicateCompletionOfArchivedRelocationIsIdempotent()
{
when(mapper.selectJobForUpdate("MOVE-DONE")).thenReturn(null);
when(mapper.isFinishedRelocation("MOVE-DONE")).thenReturn(1);
assertThat(service.completeFromWcs("MOVE-DONE").isSuccess()).isTrue();
verify(mapper, never()).moveTrayStock(any(MydoJob.class));
}
private MydoJob blockedJob(String jobId, String cargoSpaceId)
{
MydoJob job = new MydoJob();
job.setJobId(jobId);
job.setWarehouseId("03");
job.setWarehouseName("三号仓");
job.setCargoSpaceId(cargoSpaceId);
job.setStatus("1");
job.setInOrOut(20L);
return job;
}
private DenseLanePosition source(String cargoSpaceId, String trayCode)
{
DenseLanePosition position = new DenseLanePosition();
position.setCargoSpaceId(cargoSpaceId);
position.setCargoSpaceName(cargoSpaceId);
position.setWarehouseId("03");
position.setGroupCoding("SOURCE-GROUP");
position.setTheFloor(1L);
position.setTrayCode(trayCode);
return position;
}
private BasCargoSpace target(String group, String cargoSpaceId)
{
BasCargoSpace target = new BasCargoSpace();
target.setCargoSpaceId(cargoSpaceId);
target.setCargoSpaceName(cargoSpaceId);
target.setWarehouseId("03");
target.setGroupCoding(group);
target.setTheFloor(1L);
return target;
}
private MydoJobDetail detail(String stockId, String productId)
{
MydoJobDetail detail = new MydoJobDetail();
detail.setStockId(stockId);
detail.setStockinDetailId("DETAIL-" + stockId);
detail.setStockinId("IN-" + stockId);
detail.setProductId(productId);
detail.setProductName(productId);
detail.setProjectNo("PROJECT-1");
detail.setStockQuantity(BigDecimal.TEN);
return detail;
}
}

View File

@@ -4,6 +4,10 @@ import com.ruoyi.bas.id.job.service.JobIdService;
import com.ruoyi.bas.cargospace.mapper.BasCargoSpaceMapper;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.in.job.service.IMydoJobService;
import com.ruoyi.in.job.domain.MydoJob;
import com.ruoyi.out.dispatch.domain.DispatchTrigger;
import com.ruoyi.out.dispatch.service.IOutboundDispatchService;
import com.ruoyi.out.relocation.service.IRelocationJobService;
import com.ruoyi.out.stockout.mapper.OutStockoutMapper;
import com.ruoyi.out.stockoutdetail.domain.OutStockoutDetail;
import com.ruoyi.out.stockoutdetail.mapper.OutStockoutDetailMapper;
@@ -46,6 +50,12 @@ class OutStockoutServiceImplTest {
@Mock
private BasCargoSpaceMapper basCargoSpaceMapper;
@Mock
private IRelocationJobService relocationJobService;
@Mock
private IOutboundDispatchService dispatchService;
@InjectMocks
private OutStockoutServiceImpl service;
@@ -102,4 +112,35 @@ class OutStockoutServiceImplTest {
.contains("没有可分配库存明细");
verify(mydoJobService, never()).selectPendingOutMydoJobsByStockoutDetailId(any());
}
@Test
void successfulAllocationCreatesDenseBlockersAndUsesUnifiedDispatch()
{
OutStockoutDetail detail = new OutStockoutDetail();
detail.setStockoutDetailId("CKD-1");
when(outStockoutDetailMapper.selectOutStockoutDetailListByStockoutId("CKD"))
.thenReturn(List.of(detail));
doAnswer(invocation -> {
@SuppressWarnings("unchecked")
HashMap<String, Object> map = invocation.getArgument(0);
map.put("v_result", 10);
map.put("v_resultString", "自动分配完成");
return null;
}).when(outStockoutMapper).autoAssignOutStockoutByProc(any());
MydoJob job = new MydoJob();
job.setJobId("OUT-1");
when(mydoJobService.selectPendingOutMydoJobsByStockoutDetailId("CKD-1"))
.thenReturn(List.of(job));
when(relocationJobService.createBlockerRelocations("OUT-1"))
.thenReturn(List.of("MOVE-1"));
when(dispatchService.dispatch("OUT-1", DispatchTrigger.INITIAL))
.thenReturn(AjaxResult.success("作业暂未下发WCS:等待移库"));
AjaxResult result = service.autoAssignOutStockout("CKD", "02-AU-01");
assertThat(result.isSuccess()).isTrue();
verify(relocationJobService).createBlockerRelocations("OUT-1");
verify(dispatchService).dispatch("OUT-1", DispatchTrigger.INITIAL);
verify(mydoJobService, never()).pushOutJobToWcs(any());
}
}

View File

@@ -70,6 +70,39 @@ export function getDenseChartData(query) {
})
}
/**
* 密集库指定移库通道选项
*/
export function getDenseRelocationOptions(query) {
return request({
url: '/kpi/denseRelocation/options',
method: 'get',
params: query
})
}
/**
* 预览密集库指定移库托盘
*/
export function previewDenseRelocation(data) {
return request({
url: '/kpi/denseRelocation/preview',
method: 'post',
data
})
}
/**
* 生成密集库指定移库作业
*/
export function generateDenseRelocation(data) {
return request({
url: '/kpi/denseRelocation/generate',
method: 'post',
data
})
}
/**
* 货位平面图-货位详情
*/

View File

@@ -0,0 +1,28 @@
import request from '@/utils/request'
// 查询03仓密集库通道整托库存汇总
export function listDenseLaneOutbound(query) {
return request({
url: '/out/denseLaneOutbound/list',
method: 'get',
params: query
})
}
// 预览指定通道当前最外侧托盘
export function previewDenseLaneOutbound(query) {
return request({
url: '/out/denseLaneOutbound/preview',
method: 'get',
params: query
})
}
// 按预览结果生成整托出库作业
export function generateDenseLaneOutbound(data) {
return request({
url: '/out/denseLaneOutbound/generate',
method: 'post',
data: data
})
}

View File

@@ -59,6 +59,16 @@
@click="loadChartData"
>刷新</el-button>
</el-form-item>
<el-form-item>
<el-button
type="primary"
plain
icon="el-icon-rank"
size="small"
data-testid="dense-relocation-open"
@click="openRelocationDialog"
>指定移库</el-button>
</el-form-item>
</template>
<template v-else>
@@ -102,6 +112,123 @@
:loading="trayLoading"
/>
</div>
<el-dialog
title="指定同层移库"
:visible.sync="relocationDialogVisible"
width="720px"
append-to-body
:close-on-click-modal="false"
custom-class="dense-relocation-dialog"
@closed="resetRelocationDialog"
>
<div class="relocation-context">
<span>仓库 <strong>03 密集库</strong></span>
<span>当前层 <strong>{{ selectedDenseFloor }} 层</strong></span>
<span>规则 <strong>外侧优先 · 同层异通道</strong></span>
</div>
<el-form
ref="relocationForm"
:model="relocationForm"
:rules="relocationRules"
label-width="92px"
class="relocation-form"
>
<el-row :gutter="14">
<el-col :span="10">
<el-form-item label="源通道" prop="sourceGroupCoding">
<el-select
v-model="relocationForm.sourceGroupCoding"
filterable
placeholder="选择有满托盘的通道"
style="width: 100%"
@change="handleRelocationConditionChange"
>
<el-option
v-for="item in relocationSourceLanes"
:key="item.groupCoding"
:label="laneLabel(item, 'source')"
:value="item.groupCoding"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="托盘数量" prop="trayCount">
<el-input-number
v-model="relocationForm.trayCount"
:min="1"
:max="selectedSourceCapacity"
controls-position="right"
style="width: 100%"
@change="handleRelocationConditionChange"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="目标通道" prop="targetGroupCoding">
<el-select
v-model="relocationForm.targetGroupCoding"
filterable
placeholder="选择容量足够的通道"
style="width: 100%"
@change="invalidateRelocationPreview"
>
<el-option
v-for="item in relocationTargetLanes"
:key="item.groupCoding"
:label="laneLabel(item, 'target')"
:value="item.groupCoding"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="relocation-preview-head">
<div>
<strong>外侧托盘预览</strong>
<span v-if="relocationPreview.trays.length">
{{ relocationPreview.trays.length }} 托 / 库存总量 {{ relocationPreview.totalQuantity || 0 }}
</span>
<span v-else>选择通道后先预览,提交时后端会再次校验</span>
</div>
<el-button
size="mini"
icon="el-icon-view"
:loading="relocationPreviewLoading"
@click="handlePreviewRelocation"
>刷新预览</el-button>
</div>
<el-table
:data="relocationPreview.trays"
height="248"
size="mini"
border
empty-text="暂无预览数据"
data-testid="dense-relocation-preview"
>
<el-table-column type="index" label="顺序" width="58" align="center" />
<el-table-column prop="trayCode" label="托盘码" min-width="145" show-overflow-tooltip />
<el-table-column prop="cargoSpaceName" label="源货位" min-width="170" show-overflow-tooltip>
<template slot-scope="scope">
{{ scope.row.cargoSpaceName || scope.row.cargoSpaceId }}
</template>
</el-table-column>
<el-table-column prop="groupCoding" label="源通道" min-width="130" show-overflow-tooltip />
<el-table-column prop="stockQuantity" label="库存数量" width="100" align="right" />
</el-table>
<span slot="footer" class="dialog-footer">
<el-button @click="relocationDialogVisible = false">取消</el-button>
<el-button
type="primary"
:disabled="!relocationPreviewValid"
:loading="relocationGenerating"
@click="handleGenerateRelocation"
>生成移库作业</el-button>
</span>
</el-dialog>
</div>
</template>
@@ -109,13 +236,19 @@
import { listWarehouse } from '@/api/bas/warehouse'
import { listRoadway } from '@/api/bas/roadway'
import { getPlatoons } from '@/api/bas/cargospace'
import { getChartData, getDenseChartData } from '@/api/kpi/kpi'
import {
getChartData,
getDenseChartData,
getDenseRelocationOptions,
previewDenseRelocation,
generateDenseRelocation
} from '@/api/kpi/kpi'
import DenseCargoSpaceMap from './components/DenseCargoSpaceMap.vue'
import TrayCargoSpaceMap from './components/TrayCargoSpaceMap.vue'
export default {
// 必须与菜单路由名称一致,keep-alive 才能缓存,避免切页/聚焦后整页重载
name: '货位平面图',
name: 'PlanKpi',
components: {
DenseCargoSpaceMap,
TrayCargoSpaceMap
@@ -135,6 +268,26 @@ export default {
denseFloors: [],
denseLocations: [],
selectedDenseFloor: 1,
relocationDialogVisible: false,
relocationOptionsLoading: false,
relocationPreviewLoading: false,
relocationGenerating: false,
relocationSourceLanes: [],
relocationTargetLanes: [],
relocationPreview: { trays: [], totalQuantity: 0 },
relocationPreviewSignature: '',
relocationForm: {
warehouseId: '03',
theFloor: 1,
sourceGroupCoding: '',
targetGroupCoding: '',
trayCount: 1
},
relocationRules: {
sourceGroupCoding: [{ required: true, message: '请选择源通道', trigger: 'change' }],
targetGroupCoding: [{ required: true, message: '请选择目标通道', trigger: 'change' }],
trayCount: [{ required: true, message: '请输入托盘数量', trigger: 'change' }]
},
trayLoading: false,
trayLocations: [],
trayColumns: [],
@@ -151,6 +304,16 @@ export default {
computed: {
isDenseWarehouse() {
return String(this.queryParams.warehouseId) === '03'
},
selectedSourceCapacity() {
const selected = this.relocationSourceLanes.find(
item => item.groupCoding === this.relocationForm.sourceGroupCoding
)
return selected && selected.fullTrayCount ? Number(selected.fullTrayCount) : 1
},
relocationPreviewValid() {
return this.relocationPreview.trays.length === Number(this.relocationForm.trayCount) &&
this.relocationPreviewSignature === this.buildRelocationSignature()
}
},
mounted() {
@@ -291,6 +454,90 @@ export default {
}).finally(() => {
this.trayLoading = false
})
},
openRelocationDialog() {
this.relocationForm = {
warehouseId: '03',
theFloor: Number(this.selectedDenseFloor),
sourceGroupCoding: '',
targetGroupCoding: '',
trayCount: 1
}
this.invalidateRelocationPreview()
this.relocationDialogVisible = true
this.loadRelocationOptions()
},
resetRelocationDialog() {
this.relocationSourceLanes = []
this.relocationTargetLanes = []
this.invalidateRelocationPreview()
if (this.$refs.relocationForm) this.$refs.relocationForm.clearValidate()
},
loadRelocationOptions() {
this.relocationOptionsLoading = true
getDenseRelocationOptions(this.relocationForm).then(response => {
const data = response.data || {}
this.relocationSourceLanes = data.sourceLanes || []
this.relocationTargetLanes = data.targetLanes || []
if (this.relocationForm.targetGroupCoding &&
!this.relocationTargetLanes.some(item => item.groupCoding === this.relocationForm.targetGroupCoding)) {
this.relocationForm.targetGroupCoding = ''
}
}).finally(() => {
this.relocationOptionsLoading = false
})
},
handleRelocationConditionChange() {
if (this.relocationForm.trayCount > this.selectedSourceCapacity) {
this.relocationForm.trayCount = this.selectedSourceCapacity
}
this.relocationForm.targetGroupCoding = ''
this.invalidateRelocationPreview()
this.loadRelocationOptions()
},
invalidateRelocationPreview() {
this.relocationPreview = { trays: [], totalQuantity: 0 }
this.relocationPreviewSignature = ''
},
buildRelocationSignature() {
const form = this.relocationForm
return [form.warehouseId, form.theFloor, form.sourceGroupCoding,
form.targetGroupCoding, form.trayCount].join('|')
},
laneLabel(item, type) {
const count = type === 'source' ? item.fullTrayCount : item.emptyLocationCount
return `${item.groupCoding}(${type === 'source' ? '满托' : '空位'} ${count || 0})`
},
handlePreviewRelocation() {
this.$refs.relocationForm.validate(valid => {
if (!valid) return
this.relocationPreviewLoading = true
previewDenseRelocation(this.relocationForm).then(response => {
this.relocationPreview = response.data || { trays: [], totalQuantity: 0 }
this.relocationPreviewSignature = this.buildRelocationSignature()
}).finally(() => {
this.relocationPreviewLoading = false
})
})
},
handleGenerateRelocation() {
if (!this.relocationPreviewValid) {
this.$modal.msgWarning('预览已失效,请重新预览')
return
}
this.$modal.confirm(
`确认将外侧 ${this.relocationForm.trayCount} 个托盘移至通道 ${this.relocationForm.targetGroupCoding}?`
).then(() => {
this.relocationGenerating = true
return generateDenseRelocation(this.relocationForm)
}).then(response => {
const ids = response.data || []
this.$modal.msgSuccess(`已生成 ${ids.length} 个移库作业`)
this.relocationDialogVisible = false
this.loadDenseChartData()
}).finally(() => {
this.relocationGenerating = false
})
}
}
}
@@ -320,4 +567,53 @@ export default {
.chart-container--tray {
background: transparent;
}
.relocation-context {
display: flex;
align-items: center;
gap: 18px;
margin: -6px 0 18px;
padding: 9px 12px;
border-left: 3px solid #2c7a8f;
color: #627789;
background: #f2f7f9;
font-size: 12px;
}
.relocation-context strong {
margin-left: 4px;
color: #183f52;
}
.relocation-form ::v-deep .el-form-item {
margin-bottom: 16px;
}
.relocation-preview-head {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 38px;
padding: 0 2px;
border-top: 1px solid #e3e9ee;
}
.relocation-preview-head strong {
margin-right: 10px;
color: #213f52;
font-size: 13px;
}
.relocation-preview-head span {
color: #7a8d9c;
font-size: 12px;
}
@media (max-width: 760px) {
.relocation-context {
align-items: flex-start;
flex-direction: column;
gap: 5px;
}
}
</style>

View File

@@ -0,0 +1,553 @@
<template>
<div class="app-container dense-lane-outbound">
<el-form
ref="queryForm"
:model="queryParams"
size="small"
:inline="true"
v-show="showSearch"
label-width="76px"
>
<el-form-item label="仓库">
<el-input value="03仓密集库" disabled class="warehouse-input" />
</el-form-item>
<el-form-item label="通道编码" prop="groupCoding">
<el-input
v-model="queryParams.groupCoding"
placeholder="请输入通道组编码"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="层" prop="theFloor">
<el-input-number
v-model="queryParams.theFloor"
:min="1"
:precision="0"
controls-position="right"
placeholder="全部"
class="floor-input"
/>
</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-alert
title="仅显示03仓有托盘的密集通道;生成时严格按当前端头顺序选择整托。"
type="info"
:closable="false"
show-icon
class="lane-hint"
/>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table
v-loading="loading"
:data="laneList"
row-key="laneKey"
border
stripe
@expand-change="handleExpandChange"
>
<el-table-column type="expand" width="44">
<template slot-scope="scope">
<div class="inventory-expand" v-loading="scope.row.inventoryLoading">
<div class="inventory-heading">
<span>当前最外侧托盘库存</span>
<span class="inventory-subtitle">
{{ scope.row.outermostTrayCode || '-' }} / {{ scope.row.outermostCargoSpaceId || '-' }}
</span>
</div>
<el-table :data="scope.row.inventoryDetails || []" size="mini" border max-height="260">
<el-table-column label="项目号" prop="projectNo" min-width="120" show-overflow-tooltip />
<el-table-column label="物料编码" prop="productId" min-width="130" show-overflow-tooltip />
<el-table-column label="物料名称" prop="productName" min-width="150" show-overflow-tooltip />
<el-table-column label="批次" prop="lotNo" min-width="110" show-overflow-tooltip />
<el-table-column label="库存数量" prop="stockQuantity" width="110" align="right" />
<el-table-column label="入库日期" prop="inDate" width="160" />
<template slot="empty">
<div class="empty-detail">{{ scope.row.inventoryLoaded ? '当前端头托盘无库存明细' : '正在读取库存明细' }}</div>
</template>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column label="通道组编码" prop="groupCoding" min-width="140" fixed="left" show-overflow-tooltip />
<el-table-column label="货区" prop="cargoArea" width="90" align="center" />
<el-table-column label="排" prop="thePlatoon" width="72" align="center" />
<el-table-column label="层" prop="theFloor" width="72" align="center" />
<el-table-column label="出库方向" prop="directionType" width="110" align="center">
<template slot-scope="scope">
<el-tag size="mini" effect="plain">{{ directionLabel(scope.row.directionType) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="满托盘数" prop="fullTrayCount" width="105" align="right" />
<el-table-column label="可出库托盘" prop="availableTrayCount" width="120" align="right">
<template slot-scope="scope">
<span :class="{ 'available-count': Number(scope.row.availableTrayCount) > 0 }">
{{ scope.row.availableTrayCount || 0 }}
</span>
</template>
</el-table-column>
<el-table-column label="总库存" prop="totalQuantity" min-width="110" align="right" />
<el-table-column label="当前最外侧托盘" prop="outermostTrayCode" min-width="145" show-overflow-tooltip>
<template slot-scope="scope">
<span>{{ scope.row.outermostTrayCode || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="端头货位" prop="outermostCargoSpaceId" min-width="130" show-overflow-tooltip />
<el-table-column label="操作" width="120" align="center" fixed="right">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-s-promotion"
:disabled="Number(scope.row.availableTrayCount || 0) < 1"
v-hasPermi="['out:denseLaneOutbound:generate']"
@click="openGenerateDialog(scope.row)"
>整托出库</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog
title="指定通道整托出库"
:visible.sync="dialogVisible"
width="920px"
append-to-body
:close-on-click-modal="false"
@closed="resetDialog"
>
<el-descriptions v-if="currentLane" :column="4" size="small" border class="lane-summary">
<el-descriptions-item label="通道">{{ currentLane.groupCoding }}</el-descriptions-item>
<el-descriptions-item label="货区 / 排">{{ currentLane.cargoArea || '-' }} / {{ currentLane.thePlatoon || '-' }}</el-descriptions-item>
<el-descriptions-item label="层">{{ currentLane.theFloor }}</el-descriptions-item>
<el-descriptions-item label="可出库">{{ currentLane.availableTrayCount || 0 }} 托</el-descriptions-item>
</el-descriptions>
<el-form :model="generateForm" size="small" :inline="true" label-width="86px" class="generate-form">
<el-form-item label="整托数量" required>
<el-input-number
v-model="generateForm.trayCount"
:min="1"
:max="currentLane ? Number(currentLane.availableTrayCount || 1) : 1"
:precision="0"
controls-position="right"
@change="handleTrayCountChange"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
plain
icon="el-icon-view"
:loading="previewLoading"
v-hasPermi="['out:denseLaneOutbound:preview']"
@click="loadPreview"
>刷新预览</el-button>
</el-form-item>
<el-form-item label="出库口" required class="exit-form-item">
<el-select
v-model="generateForm.exitId"
placeholder="请选择与托盘类型匹配的出库口"
filterable
clearable
:loading="exitLoading"
class="exit-select"
>
<el-option
v-for="point in compatibleExitOptions"
:key="point.pointId"
:label="formatExitLabel(point)"
:value="point.pointId"
/>
</el-select>
</el-form-item>
</el-form>
<div class="preview-header">
<div>
<span class="preview-title">本次准确出库托盘</span>
<span class="preview-note">提交前会再次校验端头顺序和库存</span>
</div>
<div class="preview-total">合计库存:<strong>{{ previewData.totalQuantity || 0 }}</strong></div>
</div>
<el-table
v-loading="previewLoading"
:data="previewData.trays || []"
row-key="cargoSpaceId"
border
max-height="390"
>
<el-table-column type="expand" width="42">
<template slot-scope="scope">
<el-table :data="scope.row.stockDetails || []" size="mini" border class="preview-detail-table">
<el-table-column label="项目号" prop="projectNo" min-width="110" show-overflow-tooltip />
<el-table-column label="物料编码" prop="productId" min-width="120" show-overflow-tooltip />
<el-table-column label="物料名称" prop="productName" min-width="140" show-overflow-tooltip />
<el-table-column label="批次" prop="lotNo" min-width="100" show-overflow-tooltip />
<el-table-column label="库存数量" prop="stockQuantity" width="105" align="right" />
</el-table>
</template>
</el-table-column>
<el-table-column label="顺序" type="index" width="62" align="center" />
<el-table-column label="托盘号" prop="trayCode" min-width="140" show-overflow-tooltip />
<el-table-column label="货位编码" prop="cargoSpaceId" min-width="140" show-overflow-tooltip />
<el-table-column label="货位名称" prop="cargoSpaceName" min-width="140" show-overflow-tooltip />
<el-table-column label="列" prop="theColumn" width="72" align="center" />
<el-table-column label="库存数量" prop="stockQuantity" width="115" align="right" />
</el-table>
<div slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="generating"
:disabled="!canGenerate"
@click="confirmGenerate"
>确认生成 {{ generateForm.trayCount || 0 }} 托出库作业</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import {
listDenseLaneOutbound,
previewDenseLaneOutbound,
generateDenseLaneOutbound
} from '@/api/out/denseLaneOutbound'
import { listStationPoint } from '@/api/bas/stationpoint'
const emptyPreview = () => ({ trays: [], totalQuantity: 0, previewToken: null })
export default {
name: 'DenseLaneOutbound',
data() {
return {
loading: false,
showSearch: true,
laneList: [],
queryParams: {
warehouseId: '03',
groupCoding: null,
theFloor: null
},
dialogVisible: false,
currentLane: null,
previewLoading: false,
generating: false,
exitLoading: false,
exitOptions: [],
previewData: emptyPreview(),
generateForm: {
warehouseId: '03',
groupCoding: null,
theFloor: null,
trayCount: 1,
exitId: null,
previewToken: null
}
}
},
computed: {
compatibleExitOptions() {
const trayTypes = Array.from(new Set((this.previewData.trays || [])
.map(tray => this.trayType(tray.trayCode))
.filter(Boolean)))
return this.exitOptions.filter(point => trayTypes.every(type => this.pointSupportsTrayType(point, type)))
},
canGenerate() {
return Boolean(
this.previewData.previewToken &&
this.generateForm.exitId &&
(this.previewData.trays || []).length === Number(this.generateForm.trayCount)
)
}
},
created() {
this.getList()
},
methods: {
laneKey(row) {
return `${row.groupCoding}-${row.theFloor}`
},
getList() {
this.loading = true
listDenseLaneOutbound(this.queryParams).then(response => {
this.laneList = (response.data || []).map(row => ({
...row,
inventoryLoading: false,
inventoryLoaded: false,
inventoryDetails: []
}))
this.loading = false
}).catch(() => {
this.loading = false
})
},
handleQuery() {
this.getList()
},
resetQuery() {
this.resetForm('queryForm')
this.queryParams.warehouseId = '03'
this.getList()
},
handleExpandChange(row, expandedRows) {
const expanded = expandedRows.some(item => this.laneKey(item) === this.laneKey(row))
if (!expanded || row.inventoryLoaded || row.inventoryLoading || !row.outermostTrayCode) {
return
}
this.$set(row, 'inventoryLoading', true)
previewDenseLaneOutbound(this.previewParams(row, 1)).then(response => {
const tray = ((response.data || {}).trays || [])[0]
this.$set(row, 'inventoryDetails', tray ? (tray.stockDetails || []) : [])
this.$set(row, 'inventoryLoaded', true)
this.$set(row, 'inventoryLoading', false)
}).catch(() => {
this.$set(row, 'inventoryLoading', false)
})
},
openGenerateDialog(row) {
if (Number(row.availableTrayCount || 0) < 1) {
this.$modal.msgWarning('当前通道没有可出库的端头托盘')
return
}
this.currentLane = row
this.generateForm = {
warehouseId: '03',
groupCoding: row.groupCoding,
theFloor: row.theFloor,
trayCount: 1,
exitId: null,
previewToken: null
}
this.previewData = emptyPreview()
this.dialogVisible = true
this.loadExitOptions()
this.loadPreview()
},
resetDialog() {
this.currentLane = null
this.previewData = emptyPreview()
this.exitOptions = []
this.generateForm.previewToken = null
},
handleTrayCountChange() {
this.previewData = emptyPreview()
this.generateForm.previewToken = null
this.generateForm.exitId = null
},
previewParams(row, trayCount) {
return {
warehouseId: '03',
groupCoding: row.groupCoding,
theFloor: row.theFloor,
trayCount: trayCount
}
},
loadPreview() {
if (!this.currentLane || !this.generateForm.trayCount) {
return
}
this.previewLoading = true
this.generateForm.exitId = null
previewDenseLaneOutbound(this.previewParams(this.currentLane, this.generateForm.trayCount)).then(response => {
this.previewData = response.data || emptyPreview()
this.generateForm.previewToken = this.previewData.previewToken
this.selectOnlyCompatibleExit()
this.previewLoading = false
}).catch(() => {
this.previewData = emptyPreview()
this.generateForm.previewToken = null
this.previewLoading = false
})
},
loadExitOptions() {
this.exitLoading = true
listStationPoint({ warehouseId: '03', status: '0' }).then(response => {
const rows = response.rows || response.data || []
this.exitOptions = rows.filter(point => ['2', '3'].includes(String(point.pointType)) &&
String(point.pointStatus || '0') === '0')
this.selectOnlyCompatibleExit()
this.exitLoading = false
}).catch(() => {
this.exitOptions = []
this.exitLoading = false
})
},
selectOnlyCompatibleExit() {
this.$nextTick(() => {
if (this.compatibleExitOptions.length === 1) {
this.generateForm.exitId = this.compatibleExitOptions[0].pointId
}
})
},
trayType(trayCode) {
return trayCode ? String(trayCode).trim().slice(0, 1).toUpperCase() : null
},
pointSupportsTrayType(point, trayType) {
if (!trayType || !point.trayType) {
return true
}
return String(point.trayType).split(',').map(item => item.trim().toUpperCase()).includes(trayType)
},
formatExitLabel(point) {
const typeName = String(point.pointType) === '3' ? '入出库' : '出库'
const trayType = point.trayType ? `,${point.trayType}类托盘` : ''
return `${point.pointName || point.pointId}(${point.pointId},${typeName}${trayType})`
},
directionLabel(directionType) {
if (String(directionType) === '1') return '大列号端'
if (String(directionType) === '2') return '小列号端'
if (String(directionType) === '3') return '最近端头'
return '-'
},
confirmGenerate() {
if (!this.canGenerate) {
this.$modal.msgWarning('请先刷新预览并选择匹配的出库口')
return
}
const trays = (this.previewData.trays || []).map(item => item.trayCode).join('、')
this.$modal.confirm(`确认按当前端头顺序生成 ${this.generateForm.trayCount} 托出库作业?托盘:${trays}`).then(() => {
this.generating = true
return generateDenseLaneOutbound({
...this.generateForm,
previewToken: this.previewData.previewToken
})
}).then(response => {
const ids = response.data || []
this.$modal.msgSuccess(`已生成 ${ids.length} 条出库作业`)
this.generating = false
this.dialogVisible = false
this.getList()
}).catch(error => {
this.generating = false
if (error && error.message && error.message.includes('预览已失效')) {
this.$modal.msgWarning('端头托盘或库存已变化,已为你刷新预览,请重新确认')
this.loadPreview()
}
})
}
}
}
</script>
<style scoped>
.dense-lane-outbound .warehouse-input {
width: 150px;
}
.dense-lane-outbound .floor-input {
width: 128px;
}
.lane-hint {
width: auto;
padding: 5px 12px;
}
.available-count {
color: #1890ff;
font-weight: 600;
}
.inventory-expand {
padding: 8px 18px 14px 58px;
background: #f8fafc;
}
.inventory-heading,
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
color: #303133;
font-size: 14px;
font-weight: 600;
}
.inventory-subtitle,
.preview-note {
margin-left: 10px;
color: #909399;
font-size: 12px;
font-weight: 400;
}
.empty-detail {
color: #909399;
}
.lane-summary {
margin-bottom: 16px;
}
.generate-form {
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 12px 12px 0;
margin-bottom: 14px;
border: 1px solid #ebeef5;
background: #f8fafc;
}
.exit-form-item {
flex: 1;
min-width: 360px;
}
.exit-select {
width: 100%;
}
.preview-title {
color: #303133;
}
.preview-total {
color: #606266;
font-weight: 400;
}
.preview-total strong {
color: #1890ff;
font-size: 16px;
}
.preview-detail-table {
width: calc(100% - 48px);
margin: 4px 18px 10px 48px;
}
@media (max-width: 768px) {
.inventory-expand {
padding-left: 10px;
}
.generate-form {
display: block;
}
.exit-form-item {
min-width: 100%;
}
.preview-header {
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
}
</style>

View File

@@ -109,15 +109,19 @@
>手动完成</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="primary"
plain
icon="el-icon-upload2"
size="mini"
:disabled="single"
@click="handlePushWcs"
v-hasPermi="['out:job:edit']"
>推送WCS</el-button>
<el-tooltip content="手动下发 WCS" placement="top">
<span>
<el-button
type="primary"
plain
icon="el-icon-s-promotion"
size="mini"
:disabled="single || !canPushWcs(selectedRows[0])"
@click="handlePushWcs"
v-hasPermi="['out:job:pushWcs']"
>手动下发 WCS</el-button>
</span>
</el-tooltip>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
@@ -154,6 +158,29 @@
<span>{{ parseTime(scope.row.createTime, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
</template>
</el-table-column>
<el-table-column label="WCS状态" align="center" prop="dispatchStatus" width="112">
<template slot-scope="scope">
<el-tag :type="dispatchStatusType(scope.row.dispatchStatus)" size="mini" effect="plain">
{{ dispatchStatusLabel(scope.row.dispatchStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="自动重试" align="center" prop="autoRetryCount" width="88">
<template slot-scope="scope">{{ scope.row.autoRetryCount || 0 }}</template>
</el-table-column>
<el-table-column label="最后下发错误" align="left" prop="lastDispatchError" min-width="160">
<template slot-scope="scope">
<el-tooltip
v-if="scope.row.lastDispatchError"
:content="scope.row.lastDispatchError"
placement="top"
effect="dark"
>
<span class="dispatch-error">{{ scope.row.lastDispatchError }}</span>
</el-tooltip>
<span v-else class="dispatch-empty">-</span>
</template>
</el-table-column>
<el-table-column label="开始时间" align="center" prop="startTime" width="160">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.startTime, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
@@ -239,6 +266,8 @@ export default {
loading: true,
// 选中数组
ids: [],
// 当前选中行
selectedRows: [],
// 非单个禁用
single: true,
// 显示搜索条件
@@ -290,6 +319,8 @@ export default {
this.jobList = response.rows
this.total = response.total
this.loading = false
}).catch(() => {
this.loading = false
})
},
/** 搜索按钮操作 */
@@ -313,6 +344,7 @@ export default {
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.jobId)
this.selectedRows = selection
this.single = selection.length !== 1
},
/** 手动作废按钮操作 */
@@ -337,14 +369,59 @@ export default {
},
/** 推送 WCS 按钮操作 */
handlePushWcs() {
const jobId = this.ids[0]
this.$modal.confirm('是否确认推送选中的出库作业到WCS?').then(() => {
return pushJobToWcs(jobId)
}).then(() => {
this.$modal.msgSuccess("推送成功")
const row = this.selectedRows[0]
if (!this.canPushWcs(row)) {
this.$modal.msgWarning('当前作业状态不允许手动下发 WCS')
return
}
const autoRetries = Number(row.autoRetryCount || 0)
const manualRetries = Number(row.manualRetryCount || 0)
const lastError = row.lastDispatchError || '无'
const message = `确认手动下发作业 ${row.jobId} 到 WCS?自动重试 ${autoRetries} 次,人工下发 ${manualRetries} 次;最后错误:${lastError}`
this.$modal.confirm(message).then(() => {
return pushJobToWcs(row.jobId)
}).then(response => {
const resultMessage = response.msg || 'WCS手动下发请求已处理'
if (resultMessage.includes('下发成功')) {
this.$modal.msgSuccess(resultMessage)
} else {
this.$modal.msgWarning(resultMessage)
}
this.getList()
}).catch(() => {})
},
canPushWcs(row) {
if (!row || String(row.status) !== '1') {
return false
}
return ['PENDING', 'RETRY_WAIT', 'EXHAUSTED', 'BLOCKED']
.includes(this.normalizeDispatchStatus(row.dispatchStatus))
},
normalizeDispatchStatus(status) {
return status || 'PENDING'
},
dispatchStatusLabel(status) {
const labels = {
PENDING: '待下发',
SENDING: '下发中',
BLOCKED: '通道阻挡',
RETRY_WAIT: '等待重试',
SUCCESS: '已下发',
EXHAUSTED: '重试耗尽'
}
return labels[this.normalizeDispatchStatus(status)] || status || '待下发'
},
dispatchStatusType(status) {
const types = {
PENDING: 'info',
SENDING: 'warning',
BLOCKED: 'warning',
RETRY_WAIT: 'warning',
SUCCESS: 'success',
EXHAUSTED: 'danger'
}
return types[this.normalizeDispatchStatus(status)] || 'info'
},
/** 点击主作业行 —— 联动刷新子作业 */
handleRowClick(row) {
this.currentJob = row
@@ -368,3 +445,19 @@ export default {
}
}
</script>
<style scoped>
.dispatch-error {
display: inline-block;
max-width: 100%;
overflow: hidden;
color: #f56c6c;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.dispatch-empty {
color: #c0c4cc;
}
</style>

View File

@@ -0,0 +1,168 @@
"""Read-only Playwright verification for dense outbound and relocation screens."""
import json
import os
from pathlib import Path
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
from playwright.sync_api import sync_playwright
BASE_URL = os.getenv("WMS_TEST_BASE_URL", "http://127.0.0.1")
USERNAME = os.environ["WMS_TEST_USERNAME"]
PASSWORD = os.environ["WMS_TEST_PASSWORD"]
OUTPUT_DIR = Path(os.getenv(
"WMS_TEST_SCREENSHOT_DIR",
"docs/testing/screenshots/2026-07-16-dense-outbound",
)).resolve()
def wait_for_app(page):
try:
page.wait_for_load_state("networkidle", timeout=15000)
except PlaywrightTimeoutError:
page.wait_for_load_state("domcontentloaded")
page.wait_for_timeout(800)
def assert_text(page, *labels):
body = page.locator("body").inner_text()
for label in labels:
if label not in body:
raise AssertionError(f"Missing text {label!r} at {page.url}")
def page_overflow(page):
return page.evaluate("""() => ({
viewport: window.innerWidth,
documentWidth: document.documentElement.scrollWidth,
bodyWidth: document.body.scrollWidth
})""")
def wait_for_preview_dialog(page):
dialog = page.locator(".el-dialog:visible")
dialog.get_by_text("指定通道整托出库", exact=True).wait_for(timeout=10000)
dialog.locator(".el-loading-mask").wait_for(state="hidden", timeout=10000)
dialog.locator("tbody .el-table__row").first.wait_for(timeout=10000)
def main():
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
results = {"pages": {}, "consoleErrors": [], "requestFailures": []}
with sync_playwright() as playwright:
browser = playwright.chromium.launch(channel="chrome", headless=True)
context = browser.new_context(viewport={"width": 1440, "height": 1000})
page = context.new_page()
page.on("console", lambda msg: results["consoleErrors"].append(f"{page.url} :: {msg.text}")
if msg.type == "error" else None)
page.on("requestfailed", lambda request: results["requestFailures"].append(
f"{request.method} {request.url}: {request.failure}"))
page.goto(BASE_URL + "/login", wait_until="domcontentloaded")
wait_for_app(page)
page.get_by_placeholder("账号").fill(USERNAME)
page.get_by_placeholder("密码").fill(PASSWORD)
page.get_by_role("button", name="登 录").click()
page.wait_for_url(lambda url: "/login" not in url, timeout=15000)
wait_for_app(page)
pages = [
("outbound-job", "/out/outJob",
("WCS状态", "自动重试", "最后下发错误", "手动下发 WCS")),
("dense-lane-outbound", "/out/denseLaneOutbound",
("通道组编码", "满托盘数", "可出库托盘", "当前最外侧托盘", "整托出库")),
("kpi-plan", "/kpi/planKpi",
("货位平面图", "指定移库")),
]
for name, route, labels in pages:
page.goto(BASE_URL + route, wait_until="domcontentloaded")
wait_for_app(page)
assert_text(page, *labels)
screenshot = OUTPUT_DIR / f"{name}-desktop.png"
page.screenshot(path=str(screenshot), full_page=True)
results["pages"][name] = {
"url": page.url,
"desktopScreenshot": str(screenshot),
"desktopOverflow": page_overflow(page),
}
if name == "dense-lane-outbound":
lane_buttons = page.get_by_role("button", name="整托出库")
enabled_button = next((lane_buttons.nth(index)
for index in range(lane_buttons.count())
if lane_buttons.nth(index).is_enabled()), None)
if enabled_button is not None:
enabled_button.click()
wait_for_preview_dialog(page)
assert_text(page, "整托数量", "出库口", "本次准确出库托盘")
dialog_shot = OUTPUT_DIR / "dense-lane-outbound-dialog-real.png"
page.screenshot(path=str(dialog_shot), full_page=True)
results["pages"][name]["realDialogScreenshot"] = str(dialog_shot)
page.get_by_role("button", name="取消").last.click()
else:
results["pages"][name]["dialog"] = "skipped-no-available-lane"
synthetic_lane = {
"groupCoding": "VERIFY-LANE-01", "cargoArea": "G", "thePlatoon": 7,
"theFloor": 1, "directionType": "1", "fullTrayCount": 3,
"availableTrayCount": 2, "totalQuantity": 24,
"outermostTrayCode": "A-VERIFY-001", "outermostCargoSpaceId": "VERIFY-CS-003",
}
synthetic_detail = {
"projectNo": "VERIFY-PROJECT", "productId": "VERIFY-MATERIAL",
"productName": "验证物料", "lotNo": "VERIFY-LOT", "stockQuantity": 12,
}
synthetic_tray = {
"cargoSpaceId": "VERIFY-CS-003", "cargoSpaceName": "验证端头货位",
"trayCode": "A-VERIFY-001", "theColumn": 3, "stockQuantity": 12,
"stockDetails": [synthetic_detail],
}
page.route("**/dev-api/out/denseLaneOutbound/list*", lambda route: route.fulfill(
status=200, content_type="application/json",
body=json.dumps({"code": 200, "data": [synthetic_lane]}, ensure_ascii=False)))
page.route("**/dev-api/out/denseLaneOutbound/preview*", lambda route: route.fulfill(
status=200, content_type="application/json",
body=json.dumps({"code": 200, "data": {"trays": [synthetic_tray],
"totalQuantity": 12, "previewToken": "verify-token"}}, ensure_ascii=False)))
page.route("**/dev-api/bas/stationpoint/list*", lambda route: route.fulfill(
status=200, content_type="application/json",
body=json.dumps({"code": 200, "rows": [{"pointId": "VERIFY-OUT-1",
"pointName": "验证出库口", "pointType": "2", "pointStatus": "0",
"status": "0", "trayType": "A"}], "total": 1}, ensure_ascii=False)))
page.set_viewport_size({"width": 1440, "height": 1000})
page.goto(BASE_URL + "/out/denseLaneOutbound", wait_until="domcontentloaded")
wait_for_app(page)
page.get_by_role("button", name="整托出库").click()
wait_for_preview_dialog(page)
page.get_by_placeholder("请选择与托盘类型匹配的出库口").click()
page.get_by_text("验证出库口(VERIFY-OUT-1,出库,A类托盘)", exact=True).wait_for(timeout=5000)
assert_text(page, "VERIFY-LANE-01", "A-VERIFY-001", "VERIFY-CS-003",
"验证出库口", "本次准确出库托盘")
dialog_shot = OUTPUT_DIR / "dense-lane-outbound-dialog.png"
page.screenshot(path=str(dialog_shot), full_page=True)
results["pages"]["dense-lane-outbound"]["mockDialogScreenshot"] = str(dialog_shot)
results["pages"]["dense-lane-outbound"]["mockDialog"] = "verified-with-read-only-mocks"
page.set_viewport_size({"width": 390, "height": 844})
page.goto(BASE_URL + "/out/denseLaneOutbound", wait_until="domcontentloaded")
wait_for_app(page)
assert_text(page, "指定通道", "通道组编码")
narrow_shot = OUTPUT_DIR / "dense-lane-outbound-narrow.png"
page.screenshot(path=str(narrow_shot), full_page=True)
results["pages"]["dense-lane-outbound"]["narrowScreenshot"] = str(narrow_shot)
results["pages"]["dense-lane-outbound"]["narrowOverflow"] = page_overflow(page)
context.close()
browser.close()
ignored_console_fragments = ("ResizeObserver loop",)
results["consoleErrors"] = [message for message in results["consoleErrors"]
if not any(fragment in message for fragment in ignored_console_fragments)]
if results["consoleErrors"] or results["requestFailures"]:
raise AssertionError(json.dumps(results, ensure_ascii=False, indent=2))
print(json.dumps(results, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,287 @@
-- 密集库出库、移库及 WCS 下发补偿
-- 目标库:FTC25009-WMS
SET NOCOUNT ON;
IF OBJECT_ID(N'dbo.wcs_job_dispatch', N'U') IS NULL
BEGIN
CREATE TABLE dbo.wcs_job_dispatch
(
job_id nvarchar(255) NOT NULL,
dispatch_status nvarchar(20) NOT NULL CONSTRAINT DF_wcs_job_dispatch_status DEFAULT N'PENDING',
auto_retry_count int NOT NULL CONSTRAINT DF_wcs_job_dispatch_auto_retry DEFAULT 0,
manual_retry_count int NOT NULL CONSTRAINT DF_wcs_job_dispatch_manual_retry DEFAULT 0,
last_error nvarchar(1000) NULL,
last_attempt_time datetime2 NULL,
next_retry_time datetime2 NULL,
success_time datetime2 NULL,
version_no int NOT NULL CONSTRAINT DF_wcs_job_dispatch_version DEFAULT 0,
create_time datetime2 NOT NULL CONSTRAINT DF_wcs_job_dispatch_create_time DEFAULT SYSDATETIME(),
update_time datetime2 NOT NULL CONSTRAINT DF_wcs_job_dispatch_update_time DEFAULT SYSDATETIME(),
CONSTRAINT PK_wcs_job_dispatch PRIMARY KEY CLUSTERED (job_id),
CONSTRAINT CK_wcs_job_dispatch_status CHECK
(dispatch_status IN (N'PENDING', N'SENDING', N'BLOCKED', N'RETRY_WAIT', N'SUCCESS', N'EXHAUSTED')),
CONSTRAINT CK_wcs_job_dispatch_auto_retry CHECK (auto_retry_count BETWEEN 0 AND 5),
CONSTRAINT CK_wcs_job_dispatch_manual_retry CHECK (manual_retry_count >= 0)
);
END
GO
IF NOT EXISTS
(
SELECT 1 FROM sys.indexes
WHERE object_id = OBJECT_ID(N'dbo.wcs_job_dispatch')
AND name = N'IX_wcs_job_dispatch_retry'
)
BEGIN
CREATE NONCLUSTERED INDEX IX_wcs_job_dispatch_retry
ON dbo.wcs_job_dispatch (dispatch_status, next_retry_time, auto_retry_count, create_time)
INCLUDE (version_no);
END
GO
IF NOT EXISTS
(
SELECT 1 FROM sys.indexes
WHERE object_id = OBJECT_ID(N'dbo.mydo_job')
AND name = N'IX_mydo_job_dense_dispatch'
)
BEGIN
CREATE NONCLUSTERED INDEX IX_mydo_job_dense_dispatch
ON dbo.mydo_job (warehouse_id, cargo_space_id, in_or_out, status, execution_phase)
INCLUDE (job_id, tray_code, task_no);
END
GO
IF NOT EXISTS (SELECT 1 FROM dbo.bas_task_no WHERE id = N'23')
BEGIN
INSERT INTO dbo.bas_task_no (id, task_type, type_name, task_no, min_no, max_no)
VALUES (N'23', N'23', N'移库', 90000, 90001, 99999);
END
ELSE
BEGIN
UPDATE dbo.bas_task_no
SET task_type = N'23', type_name = N'移库'
WHERE id = N'23';
END
GO
DECLARE @retryJobId bigint;
DECLARE @retryJobIdIsIdentity bit = COLUMNPROPERTY(OBJECT_ID(N'dbo.sys_job'), 'job_id', 'IsIdentity');
SELECT TOP 1 @retryJobId = job_id
FROM dbo.sys_job
WHERE invoke_target = N'wcsOutboundRetryTask.retryFailedJobs()';
IF @retryJobId IS NULL
BEGIN
SELECT @retryJobId = ISNULL(MAX(job_id), 0) + 1 FROM dbo.sys_job;
IF @retryJobIdIsIdentity = 1
SET IDENTITY_INSERT dbo.sys_job ON;
INSERT INTO dbo.sys_job
(
job_id, job_name, job_group, invoke_target, cron_expression,
misfire_policy, concurrent, status, create_by, create_time, update_by, update_time, remark
)
VALUES
(
@retryJobId, N'WCS出库下发失败补偿', N'DEFAULT', N'wcsOutboundRetryTask.retryFailedJobs()', N'0 0/1 * * * ?',
N'3', N'1', N'0', N'admin', GETDATE(), N'', NULL, N'每分钟补偿失败或被密集库遮挡的出库作业'
);
IF @retryJobIdIsIdentity = 1
SET IDENTITY_INSERT dbo.sys_job OFF;
END
ELSE
BEGIN
UPDATE dbo.sys_job
SET job_name = N'WCS出库下发失败补偿',
job_group = N'DEFAULT',
cron_expression = N'0 0/1 * * * ?',
misfire_policy = N'3',
concurrent = N'1',
status = N'0',
update_by = N'admin',
update_time = GETDATE(),
remark = N'每分钟补偿失败或被密集库遮挡的出库作业'
WHERE job_id = @retryJobId;
END
GO
DECLARE @outMenuId bigint;
DECLARE @denseMenuId bigint;
DECLARE @previewMenuId bigint;
DECLARE @generateMenuId bigint;
DECLARE @outJobMenuId bigint;
DECLARE @pushWcsMenuId bigint;
DECLARE @menuIdIsIdentity bit = COLUMNPROPERTY(OBJECT_ID(N'dbo.sys_menu'), 'menu_id', 'IsIdentity');
SELECT TOP 1 @outMenuId = menu_id
FROM dbo.sys_menu
WHERE menu_type = N'M' AND (path = N'out' OR menu_name = N'出库管理')
ORDER BY CASE WHEN menu_id = 2001 THEN 0 ELSE 1 END, menu_id;
IF @outMenuId IS NULL
SET @outMenuId = 2001;
SELECT TOP 1 @denseMenuId = menu_id
FROM dbo.sys_menu
WHERE menu_type = N'C'
AND (perms = N'out:denseLaneOutbound:list'
OR (path = N'denseLaneOutbound' AND component = N'out/denseLaneOutbound/index'));
IF @denseMenuId IS NULL
BEGIN
SELECT @denseMenuId = ISNULL(MAX(menu_id), 2200) + 1 FROM dbo.sys_menu;
END
SELECT TOP 1 @previewMenuId = menu_id
FROM dbo.sys_menu
WHERE menu_type = N'F' AND perms = N'out:denseLaneOutbound:preview';
IF @previewMenuId IS NULL
BEGIN
SELECT @previewMenuId = ISNULL(MAX(menu_id), @denseMenuId) + 1 FROM dbo.sys_menu;
WHILE @previewMenuId IN (@denseMenuId) OR EXISTS (SELECT 1 FROM dbo.sys_menu WHERE menu_id = @previewMenuId)
SET @previewMenuId = @previewMenuId + 1;
END
SELECT TOP 1 @generateMenuId = menu_id
FROM dbo.sys_menu
WHERE menu_type = N'F' AND perms = N'out:denseLaneOutbound:generate';
IF @generateMenuId IS NULL
BEGIN
SET @generateMenuId = @previewMenuId + 1;
WHILE @generateMenuId IN (@denseMenuId, @previewMenuId) OR EXISTS (SELECT 1 FROM dbo.sys_menu WHERE menu_id = @generateMenuId)
SET @generateMenuId = @generateMenuId + 1;
END
SELECT TOP 1 @outJobMenuId = menu_id
FROM dbo.sys_menu
WHERE menu_type = N'C' AND perms = N'out:job:list'
ORDER BY menu_id;
SELECT TOP 1 @pushWcsMenuId = menu_id
FROM dbo.sys_menu
WHERE menu_type = N'F' AND perms = N'out:job:pushWcs';
IF @pushWcsMenuId IS NULL
BEGIN
SET @pushWcsMenuId = @generateMenuId + 1;
WHILE @pushWcsMenuId IN (@denseMenuId, @previewMenuId, @generateMenuId)
OR EXISTS (SELECT 1 FROM dbo.sys_menu WHERE menu_id = @pushWcsMenuId)
SET @pushWcsMenuId = @pushWcsMenuId + 1;
END
IF @menuIdIsIdentity = 1
SET IDENTITY_INSERT dbo.sys_menu ON;
IF NOT EXISTS (SELECT 1 FROM dbo.sys_menu WHERE menu_id = @denseMenuId)
BEGIN
INSERT INTO dbo.sys_menu
(
menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
is_frame, is_cache, menu_type, visible, status, perms, icon,
create_by, create_time, update_by, update_time, remark
)
VALUES
(
@denseMenuId, N'指定通道出库', @outMenuId, 5, N'denseLaneOutbound', N'out/denseLaneOutbound/index', NULL, N'',
1, 0, N'C', N'0', N'0', N'out:denseLaneOutbound:list', N'table',
N'admin', GETDATE(), N'', NULL, N'03仓密集库按通道生成端头托盘出库作业'
);
END
ELSE
BEGIN
UPDATE dbo.sys_menu
SET menu_name = N'指定通道出库', parent_id = @outMenuId, order_num = 5,
path = N'denseLaneOutbound', component = N'out/denseLaneOutbound/index',
perms = N'out:denseLaneOutbound:list', icon = N'table', visible = N'0', status = N'0',
update_by = N'admin', update_time = GETDATE()
WHERE menu_id = @denseMenuId;
END
IF NOT EXISTS (SELECT 1 FROM dbo.sys_menu WHERE menu_id = @previewMenuId)
BEGIN
INSERT INTO dbo.sys_menu
(
menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
is_frame, is_cache, menu_type, visible, status, perms, icon,
create_by, create_time, update_by, update_time, remark
)
VALUES
(
@previewMenuId, N'预览通道出库', @denseMenuId, 1, N'#', N'', NULL, N'',
1, 0, N'F', N'0', N'0', N'out:denseLaneOutbound:preview', N'#',
N'admin', GETDATE(), N'', NULL, N''
);
END
IF NOT EXISTS (SELECT 1 FROM dbo.sys_menu WHERE menu_id = @generateMenuId)
BEGIN
INSERT INTO dbo.sys_menu
(
menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
is_frame, is_cache, menu_type, visible, status, perms, icon,
create_by, create_time, update_by, update_time, remark
)
VALUES
(
@generateMenuId, N'生成通道出库', @denseMenuId, 2, N'#', N'', NULL, N'',
1, 0, N'F', N'0', N'0', N'out:denseLaneOutbound:generate', N'#',
N'admin', GETDATE(), N'', NULL, N''
);
END
IF @outJobMenuId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM dbo.sys_menu WHERE menu_id = @pushWcsMenuId)
BEGIN
INSERT INTO dbo.sys_menu
(
menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
is_frame, is_cache, menu_type, visible, status, perms, icon,
create_by, create_time, update_by, update_time, remark
)
VALUES
(
@pushWcsMenuId, N'手动下发WCS', @outJobMenuId, 6, N'#', N'', NULL, N'',
1, 0, N'F', N'0', N'0', N'out:job:pushWcs', N'#',
N'admin', GETDATE(), N'', NULL, N''
);
END
IF @menuIdIsIdentity = 1
SET IDENTITY_INSERT dbo.sys_menu OFF;
UPDATE dbo.sys_menu
SET route_name = N'PlanKpi',
update_by = N'admin',
update_time = GETDATE()
WHERE component = N'kpi/plan/index'
AND ISNULL(route_name, N'') != N'PlanKpi';
IF OBJECT_ID(N'dbo.sys_role_menu', N'U') IS NOT NULL
BEGIN
INSERT INTO dbo.sys_role_menu (role_id, menu_id)
SELECT DISTINCT rm.role_id, permission_menu.menu_id
FROM dbo.sys_role_menu rm
CROSS APPLY (VALUES (@denseMenuId), (@previewMenuId), (@generateMenuId)) permission_menu(menu_id)
WHERE rm.menu_id = @outMenuId
AND NOT EXISTS
(SELECT 1 FROM dbo.sys_role_menu current_rm
WHERE current_rm.role_id = rm.role_id AND current_rm.menu_id = permission_menu.menu_id);
IF @outJobMenuId IS NOT NULL
BEGIN
INSERT INTO dbo.sys_role_menu (role_id, menu_id)
SELECT DISTINCT rm.role_id, @pushWcsMenuId
FROM dbo.sys_role_menu rm
WHERE rm.menu_id = @outJobMenuId
AND NOT EXISTS
(SELECT 1 FROM dbo.sys_role_menu current_rm
WHERE current_rm.role_id = rm.role_id AND current_rm.menu_id = @pushWcsMenuId);
END
END
GO