Compare commits
11 Commits
main
...
codex/dens
| Author | SHA1 | Date | |
|---|---|---|---|
| cd2ceef8e6 | |||
| 10ce28f8ac | |||
| 4641bddcea | |||
| dd82132205 | |||
| 94d14d538e | |||
| c8b8748efc | |||
| 66830dc649 | |||
| 9158a5fa54 | |||
| aa6728e52b | |||
| 3ac7305724 | |||
| 94a48122b1 |
@@ -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 < 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"
|
||||
```
|
||||
@@ -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 |
|
||||
@@ -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 |
@@ -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])) {
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 货位平面图-货位详情
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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("指定移库必须选择当前层");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ruoyi.out.dispatch.domain;
|
||||
|
||||
/**
|
||||
* WCS 下发触发方式。
|
||||
*/
|
||||
public enum DispatchTrigger
|
||||
{
|
||||
INITIAL,
|
||||
AUTO_RETRY,
|
||||
MANUAL
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
466
ruoyi-admin/src/main/resources/mapper/out/DenseLaneMapper.xml
Normal file
466
ruoyi-admin/src/main/resources/mapper/out/DenseLaneMapper.xml
Normal 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
|
||||
<= 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
|
||||
<= 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 >= 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 > source_column)
|
||||
or (direction_type = '2' and the_column < source_column)
|
||||
or (direction_type = '3' and
|
||||
(distance_to_edge < source_distance
|
||||
or (distance_to_edge = source_distance and the_column < 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
|
||||
<= 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 >= #{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 >= 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
|
||||
<= 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
|
||||
<= 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 > 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 > source_space.the_column)
|
||||
or (area_config.direction_type = '2'
|
||||
and occupied.the_column < source_space.the_column)
|
||||
or (area_config.direction_type = '3'
|
||||
and case
|
||||
when occupied.the_column - lane_boundary.min_column
|
||||
<= lane_boundary.max_column - occupied.the_column
|
||||
then occupied.the_column - lane_boundary.min_column
|
||||
else lane_boundary.max_column - occupied.the_column
|
||||
end
|
||||
< case
|
||||
when source_space.the_column - lane_boundary.min_column
|
||||
<= 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>
|
||||
@@ -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) > 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 >= 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>
|
||||
@@ -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 < 5
|
||||
and (next_retry_time is null or next_retry_time <= #{now}))
|
||||
or dispatch_status = 'BLOCKED'
|
||||
or (dispatch_status = 'SENDING'
|
||||
and last_attempt_time <= 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 <= dateadd(minute, -5, #{attemptTime}))
|
||||
and (#{trigger} != 'AUTO_RETRY' or auto_retry_count < 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>
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 >= 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 < 5")
|
||||
.contains("dispatch_status = 'SENDING'")
|
||||
.contains("last_attempt_time <= 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 货位平面图-货位详情
|
||||
*/
|
||||
|
||||
28
ruoyi-ui/src/api/out/denseLaneOutbound.js
Normal file
28
ruoyi-ui/src/api/out/denseLaneOutbound.js
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
553
ruoyi-ui/src/views/out/denseLaneOutbound/index.vue
Normal file
553
ruoyi-ui/src/views/out/denseLaneOutbound/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
168
scripts/verify_dense_outbound_ui.py
Normal file
168
scripts/verify_dense_outbound_ui.py
Normal 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()
|
||||
287
sql/20260716_dense_outbound_relocation_dispatch.sql
Normal file
287
sql/20260716_dense_outbound_relocation_dispatch.sql
Normal 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
|
||||
Reference in New Issue
Block a user