diff --git a/apifox-wcs-allocation.openapi.json b/apifox-wcs-allocation.openapi.json new file mode 100644 index 0000000..18c5703 --- /dev/null +++ b/apifox-wcs-allocation.openapi.json @@ -0,0 +1,109 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "WCS 入库作业与货位分配测试", + "version": "1.0.0" + }, + "servers": [ + { "url": "http://localhost:8080" } + ], + "tags": [ + { "name": "WCS入库货位分配" } + ], + "paths": { + "/api/external/wcs/inbound/job": { + "post": { + "tags": ["WCS入库货位分配"], + "summary": "1-WCS获取入库作业", + "operationId": "claimDenseInboundJob", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/WcsDenseInboundJobRequest" }, + "example": { "trayCode": "TEST-TRAY-001", "entranceId": "IN-01", "height": 1 } + } + } + }, + "responses": { + "200": { + "description": "业务结果,code=0成功,code=1失败", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WcsResponse" } } } + } + } + } + }, + "/api/external/wcs/inbound/location/plan": { + "post": { + "tags": ["WCS入库货位分配"], + "summary": "2-WCS选择入库层(Apifox测试请求)", + "description": "实际请求地址为 /api/external/wcs/inbound/location。此处使用独立文档路径,方便在 Apifox 中保存第一阶段请求。", + "operationId": "planDenseInboundLayer", + "servers": [{ "url": "http://localhost:8080" }], + "x-apifox-folder": "WCS入库货位分配", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/WcsInboundLocationRequest" }, + "example": { "jobId": "替换为获取作业返回的jobId", "height": 1, "availableLayers": [1, 2, 3, 4, 5], "currentLayers": [2], "allocationStage": 1 } + } + } + }, + "responses": { "200": { "description": "成功时data.roadwayId为选中层", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WcsResponse" } } } } }, + "x-apifox-url": "/api/external/wcs/inbound/location" + } + }, + "/api/external/wcs/inbound/location/allocate": { + "post": { + "tags": ["WCS入库货位分配"], + "summary": "3-WCS分配具体货位(Apifox测试请求)", + "description": "实际请求地址为 /api/external/wcs/inbound/location。此处使用独立文档路径,方便在 Apifox 中保存第二阶段请求。", + "operationId": "allocateDenseInboundLocation", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/WcsInboundLocationRequest" }, + "example": { "jobId": "替换为同一个jobId", "height": 1, "availableLayers": [1, 2, 3, 4, 5], "currentLayers": [2], "allocationStage": 2 } + } + } + }, + "responses": { "200": { "description": "成功时返回cargoSpaceId和cargoSpaceName", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WcsResponse" } } } } }, + "x-apifox-url": "/api/external/wcs/inbound/location" + } + } + }, + "components": { + "schemas": { + "WcsDenseInboundJobRequest": { + "type": "object", + "required": ["trayCode", "entranceId", "height"], + "properties": { + "trayCode": { "type": "string", "description": "03仓已有待领取入库作业的托盘号" }, + "entranceId": { "type": "string", "description": "WCS入口号" }, + "height": { "type": "integer", "enum": [1, 2], "description": "货物高度层级" } + } + }, + "WcsInboundLocationRequest": { + "type": "object", + "required": ["jobId", "height", "availableLayers", "currentLayers", "allocationStage"], + "properties": { + "jobId": { "type": "string" }, + "height": { "type": "integer", "enum": [1, 2] }, + "availableLayers": { "type": "array", "minItems": 1, "items": { "type": "integer", "minimum": 1, "maximum": 5 } }, + "currentLayers": { "type": "array", "items": { "type": "integer", "minimum": 1, "maximum": 5 } }, + "allocationStage": { "type": "integer", "enum": [1, 2] } + } + }, + "WcsResponse": { + "type": "object", + "properties": { + "code": { "type": "integer", "enum": [0, 1] }, + "msg": { "type": "string" }, + "data": { "type": "object", "nullable": true, "additionalProperties": true } + } + } + } + } +} diff --git a/docs/superpowers/plans/2026-07-14-cargo-area-direction.md b/docs/superpowers/plans/2026-07-14-cargo-area-direction.md new file mode 100644 index 0000000..e69df08 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-cargo-area-direction.md @@ -0,0 +1,186 @@ +# Cargo Area Direction 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:** Convert warehouse 03 cargo-area codes from `1`–`8` to `A`–`H`, add configurable access directions, and apply those directions to dense-storage inbound and outbound candidate ordering. + +**Architecture:** `bas_cargo_area_config` is the source of truth per warehouse and area. Direction values are `1` (small-to-large), `2` (large-to-small), and `3` (both ends); missing configuration falls back to `1` so other warehouses and existing behavior remain stable. Warehouse 03 cargo spaces are migrated transactionally, and both current allocation SQL paths join the configuration table. + +**Tech Stack:** SQL Server, Spring Boot/Java 17, MyBatis XML, Vue 2, JUnit 5/AssertJ, Playwright + +--- + +### Task 1: Define executable migration contracts + +**Files:** +- Create: `ruoyi-admin/src/test/java/com/ruoyi/bas/cargospace/mapper/CargoAreaDirectionMigrationSqlTest.java` +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/pda/mapper/PdaMapperSqlTest.java` +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/bas/cargospace/mapper/BasCargoSpaceMapperSqlTest.java` + +- [ ] **Step 1: Add migration assertions for table structure, A-H conversion, and seed directions** + +```java +assertThat(sql) + .contains("create table dbo.bas_cargo_area_config") + .contains("direction_type") + .contains("nchar(unicode(n'a')") + .contains("n'a'", "n'h'"); +``` + +- [ ] **Step 2: Add mapper assertions for all three direction branches** + +```java +assertThat(lockSql) + .contains("bas_cargo_area_config") + .contains("when '1' then") + .contains("when direction_type = '2'") + .contains("when direction_type = '3'"); +``` + +- [ ] **Step 3: Run focused tests before implementation** + +Run: `mvn -pl ruoyi-admin -am -Dtest=CargoAreaDirectionMigrationSqlTest,PdaMapperSqlTest,BasCargoSpaceMapperSqlTest -Dsurefire.failIfNoSpecifiedTests=false test` + +Expected: FAIL until the migration and mapper ordering exist. + +### Task 2: Create and seed cargo-area direction configuration + +**Files:** +- Create: `sql/20260714_cargo_area_direction.sql` +- Modify: `sql/20260714_warehouse_03_cargo_spaces.sql` + +- [ ] **Step 1: Create the configuration table with a unique warehouse/area key** + +```sql +CREATE TABLE dbo.bas_cargo_area_config ( + id bigint IDENTITY(1,1) NOT NULL PRIMARY KEY, + warehouse_id nvarchar(80) NOT NULL, + cargo_area_code nvarchar(10) NOT NULL, + cargo_area_name nvarchar(80) NOT NULL, + direction_type nvarchar(2) NOT NULL, + is_active nchar(1) NOT NULL CONSTRAINT DF_bas_cargo_area_config_active DEFAULT N'1', + create_by nvarchar(64) NULL, + create_time datetime2(3) NULL, + update_by nvarchar(64) NULL, + update_time datetime2(3) NULL, + remark nvarchar(500) NULL, + CONSTRAINT UQ_bas_cargo_area_config UNIQUE (warehouse_id, cargo_area_code), + CONSTRAINT CK_bas_cargo_area_config_direction CHECK (direction_type IN (N'1', N'2', N'3')) +); +``` + +- [ ] **Step 2: Convert warehouse 03 numeric codes to letters transactionally** + +```sql +UPDATE dbo.bas_cargo_space +SET this_cargo_area = NCHAR(UNICODE(N'A') + TRY_CONVERT(int, this_cargo_area) - 1) +WHERE warehouse_id = N'03' + AND TRY_CONVERT(int, this_cargo_area) BETWEEN 1 AND 8; +``` + +- [ ] **Step 3: Seed A-H with direction `1` to preserve current ordering** + +```sql +INSERT INTO dbo.bas_cargo_area_config (warehouse_id, cargo_area_code, cargo_area_name, direction_type, is_active) +SELECT N'03', area_code, area_code + N'货区', N'1', N'1' +FROM (VALUES (N'A'),(N'B'),(N'C'),(N'D'),(N'E'),(N'F'),(N'G'),(N'H')) x(area_code); +``` + +- [ ] **Step 4: Make the warehouse-03 generator emit A-H directly** + +Use `NCHAR(UNICODE(N'A') + area_number - 1)` instead of converting the area number to text. + +### Task 3: Apply direction to inbound allocation + +**Files:** +- Modify: `ruoyi-admin/src/main/resources/mapper/pda/PdaMapper.xml` +- Modify: `ruoyi-admin/src/main/resources/mapper/bas/BasCargoSpaceMapper.xml` + +- [ ] **Step 1: Join active area configuration in the atomic WCS allocator** + +```sql +LEFT JOIN bas_cargo_area_config cac + ON cac.warehouse_id = bcs.warehouse_id + AND cac.cargo_area_code = bcs.this_cargo_area + AND cac.is_active = N'1' +``` + +- [ ] **Step 2: Calculate each area's column bounds and order by direction** + +```sql +CASE WHEN direction_type = N'1' THEN the_column END ASC, +CASE WHEN direction_type = N'2' THEN the_column END DESC, +CASE WHEN direction_type = N'3' + THEN CASE WHEN the_column - area_min_column <= area_max_column - the_column + THEN the_column - area_min_column ELSE area_max_column - the_column END +END ASC +``` + +- [ ] **Step 3: Apply identical fallback ordering to occupied-location reallocation** + +Keep height priority and platoon balancing ahead of the direction-specific column order. + +### Task 4: Apply direction to outbound inventory candidates + +**Files:** +- Modify: `sql/out_stockout_flow.sql` + +- [ ] **Step 1: Join area configuration and derive area bounds in manual allocation** + +```sql +LEFT JOIN dbo.bas_cargo_area_config cac + ON cac.warehouse_id = bs.warehouse_id + AND cac.cargo_area_code = bcs.this_cargo_area + AND cac.is_active = N'1' +``` + +- [ ] **Step 2: Put direction ordering before FIFO tie breakers** + +Order by configured column direction, then `bs.in_date`, `bs.cargo_space_id`, and `bsd.detail_id`. + +- [ ] **Step 3: Apply the same ordering to automatic allocation cursor candidates** + +Use the same `1`/`2`/`3` CASE expressions in both outbound procedures. + +### Task 5: Update dense-map area labels + +**Files:** +- Modify: `ruoyi-ui/src/views/kpi/plan/components/DenseCargoSpaceMap.vue` +- Modify: `scripts/verify_dense_cargo_space_plan.py` + +- [ ] **Step 1: Display letter area codes without numeric padding or the old `Z` prefix** + +```vue +货区 {{ activeLocation.thisCargoArea }} +``` + +- [ ] **Step 2: Add browser assertions for A-H values** + +Click an ordinary node and assert the footer contains `货区 A`–`货区 H`, never `区域 Z`. + +### Task 6: Verify and deploy + +**Files:** +- Verify: all files above + +- [ ] **Step 1: Run focused backend tests** + +Run: `mvn -pl ruoyi-admin -am -Dtest=CargoAreaDirectionMigrationSqlTest,PdaMapperSqlTest,BasCargoSpaceMapperSqlTest -Dsurefire.failIfNoSpecifiedTests=false test` + +Expected: PASS. + +- [ ] **Step 2: Execute the SQL migration in one database transaction** + +Expected: warehouse 03 has exactly 1028 ordinary nodes with A-H and eight active configuration rows. + +- [ ] **Step 3: Build the frontend** + +Run: `cd ruoyi-ui && npm run build:prod` + +Expected: build completes successfully. + +- [ ] **Step 4: Run the dense-map browser verification** + +Run: `python scripts/verify_dense_cargo_space_plan.py` + +Expected: both floors render, no overflow occurs, A-H area labels display, and legacy warehouse rendering remains visible. diff --git a/docs/superpowers/plans/2026-07-14-dense-cargo-space-plan.md b/docs/superpowers/plans/2026-07-14-dense-cargo-space-plan.md new file mode 100644 index 0000000..5180c03 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-dense-cargo-space-plan.md @@ -0,0 +1,181 @@ +# Dense Cargo Space Plan 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:** Display warehouse 03 as a floor-selectable 13排 × 25列 dense-storage map matching the supplied layout while preserving the existing heatmap for every other warehouse. + +**Architecture:** Add a dedicated read-only KPI endpoint for dense-layout coordinates so the legacy `/kpi/cargoSpacePlan` response remains unchanged. The Vue page branches only when `warehouseId === '03'`, rendering a focused dense-map component with exact coordinate placement, floor controls, special-node colors, and per-floor counts; all other warehouses continue through the current roadway/platoon/ECharts path. + +**Tech Stack:** Java 17, Spring Boot, Mockito/JUnit 5, Vue 2, Element UI, ECharts 5, SCSS, Playwright. + +--- + +### Task 1: Add a dense-plan KPI response + +**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` +- Test: `ruoyi-admin/src/test/java/com/ruoyi/kpi/service/impl/KpiServiceImplTest.java` + +- [x] **Step 1: Write the failing service test** + +Add a test that returns two warehouse-03 `BasCargoSpace` records on different floors and asserts that `getDenseCargoSpacePlanData("03")` returns sorted `floors` plus location maps containing `cargoSpaceId`, `thePlatoon`, `theColumn`, `theFloor`, `status`, `groupCoding`, and `thisCargoArea`. + +```java +Map result = service.getDenseCargoSpacePlanData("03"); +assertThat(result.get("floors")).isEqualTo(List.of(1L, 2L)); +assertThat(result.get("locations")).asList().hasSize(2); +``` + +- [x] **Step 2: Run the test and verify the missing-method failure** + +Run: `D:/BDevTools/maven/apache-maven-3.9.11/bin/mvn.cmd -pl ruoyi-admin -am -Dtest=KpiServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test` + +Expected: compilation fails because `getDenseCargoSpacePlanData` does not exist. + +- [x] **Step 3: Implement the service and endpoint** + +Add `GET /kpi/denseCargoSpacePlan?warehouseId=03` protected by `kpi:cargo:space:plan`. Query `BasCargoSpaceMapper` by warehouse, derive sorted floors, and return plain location maps without JSON-string encoding. + +```java +@GetMapping("/denseCargoSpacePlan") +@PreAuthorize("@ss.hasPermi('kpi:cargo:space:plan')") +public AjaxResult getDenseCargoSpacePlan(String warehouseId) { + return AjaxResult.success(kpiService.getDenseCargoSpacePlanData(warehouseId)); +} +``` + +- [x] **Step 4: Run the focused backend test** + +Run: `D:/BDevTools/maven/apache-maven-3.9.11/bin/mvn.cmd -pl ruoyi-admin -am -Dtest=KpiServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test` + +Expected: all `KpiServiceImplTest` tests pass. + +### Task 2: Build the floor-selectable dense map + +**Files:** +- Create: `ruoyi-ui/src/views/kpi/plan/components/DenseCargoSpaceMap.vue` +- Modify: `ruoyi-ui/src/api/kpi/kpi.js` +- Modify: `ruoyi-ui/src/views/kpi/plan/index.vue` + +- [x] **Step 1: Add the dense-plan API client** + +```javascript +export function getDenseChartData(query) { + return request({ + url: '/kpi/denseCargoSpacePlan', + method: 'get', + params: query + }) +} +``` + +- [x] **Step 2: Create the coordinate-grid component** + +Render a top axis row, 25 descending 列 rows, 13 ascending 排 columns, and a bottom axis row. Place each location with `gridColumn = thePlatoon + 1` and `gridRow = 27 - theColumn`; use status 7 brown, status 8 yellow, status 9 green, and show the eight-digit cargo-space ID inside every real node. + +```javascript +cellStyle(location) { + return { + gridColumn: String(Number(location.thePlatoon) + 1), + gridRow: String(27 - Number(location.theColumn)) + } +} +``` + +- [x] **Step 3: Branch warehouse 03 without changing legacy behavior** + +Keep the warehouse selector for all warehouses. For warehouse 03, hide roadway/platoon filters, call the dense endpoint, and show floor buttons 1-5. For any other warehouse, retain the existing roadway/platoon calls and ECharts heatmap code. + +```javascript +isDenseWarehouse() { + return String(this.queryParams.warehouseId) === '03' +} +``` + +- [x] **Step 4: Build the production frontend** + +Run: `npm run build:prod` from `ruoyi-ui`. + +Expected: Vue production build succeeds with no compile errors. + +### Task 3: Verify behavior and visual fidelity + +**Files:** +- Create: `scripts/verify_dense_cargo_space_plan.py` +- Verify: `ruoyi-ui/src/views/kpi/plan/index.vue` +- Verify: `ruoyi-ui/src/views/kpi/plan/components/DenseCargoSpaceMap.vue` + +- [x] **Step 1: Start the backend and frontend with the webapp-testing helper** + +Run `python C:/Users/13623/.agents/skills/webapp-testing/scripts/with_server.py --help` first, then start the Spring Boot backend and Vue dev server through the helper. + +- [x] **Step 2: Verify warehouse 03 in Chromium** + +Open the KPI cargo-space plan, select warehouse 03, wait for network idle, and verify floor controls, 259 current-floor nodes, the green 王-shaped mother channel, two brown lifts, two yellow conveyors, axis labels, and no console errors. + +- [x] **Step 3: Verify a legacy warehouse** + +Switch to warehouse 01 and verify roadway/platoon controls and the existing ECharts heatmap remain visible. + +- [ ] **Step 4: Commit** + +```bash +git add docs/superpowers/plans/2026-07-14-dense-cargo-space-plan.md scripts/verify_dense_cargo_space_plan.py ruoyi-admin/src/main/java/com/ruoyi/kpi/controller/KpiController.java ruoyi-admin/src/main/java/com/ruoyi/kpi/service/KpiService.java ruoyi-admin/src/main/java/com/ruoyi/kpi/service/impl/KpiServiceImpl.java ruoyi-admin/src/test/java/com/ruoyi/kpi/service/impl/KpiServiceImplTest.java ruoyi-ui/src/api/kpi/kpi.js ruoyi-ui/src/views/kpi/plan/index.vue ruoyi-ui/src/views/kpi/plan/components/DenseCargoSpaceMap.vue +git commit -m "feat: add dense warehouse floor plan" +``` + +Only perform this step when the user explicitly requests a Git commit. + +### Task 4: Make the dense map viewport adaptive + +**Files:** +- Modify: `ruoyi-ui/src/views/kpi/plan/index.vue` +- Modify: `ruoyi-ui/src/views/kpi/plan/components/DenseCargoSpaceMap.vue` +- Modify: `scripts/verify_dense_cargo_space_plan.py` + +- [x] **Step 1: Move floor actions into the existing search form** + +For warehouse 03, render `el-radio-group` floor buttons and a refresh button after the warehouse selector. Remove the dense-mode explanatory sentence. Pass only the selected floor and locations into the map component. + +```vue + +``` + +- [x] **Step 2: Remove the map header and column 25** + +Delete the warehouse eyebrow, title, subtitle, internal floor switch, and internal refresh action. Generate column labels from 24 down to 1, place data rows with `gridRow = 26 - theColumn`, and move the bottom 排 axis to row 26. + +```javascript +columnLabels() { + return Array.from({ length: 24 }, (_, index) => 24 - index) +} +``` + +- [x] **Step 3: Fit the full grid without internal scrolling** + +Use a fixed 13-column fraction grid and 24 adaptive rows. Set the viewport to `overflow: hidden`; compute the map row height from available viewport height with CSS `clamp()`, reduce summary/legend/footer vertical space, and allow cargo IDs to scale with `clamp()`. + +```scss +.dense-map__viewport { overflow: hidden; } +.dense-grid { + grid-template-columns: 36px repeat(13, minmax(0, 1fr)); + grid-template-rows: 24px repeat(24, minmax(18px, 1fr)) 28px; + width: 100%; + min-width: 0; +} +``` + +- [x] **Step 4: Rebuild and verify responsive behavior** + +Run `npm run build:prod`, then run `python scripts/verify_dense_cargo_space_plan.py`. Assert column 25 is absent, floor controls are in `.search-box`, the dense map has 259 nodes, and both `.dense-map__viewport` and `.dense-grid` have `scrollWidth <= clientWidth` and `scrollHeight <= clientHeight` at 1500×1050 and 1280×800 viewports. diff --git a/docs/superpowers/plans/2026-07-14-dense-inbound-lane-allocation.md b/docs/superpowers/plans/2026-07-14-dense-inbound-lane-allocation.md new file mode 100644 index 0000000..63bdb59 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-dense-inbound-lane-allocation.md @@ -0,0 +1,191 @@ +# 03 Warehouse Dense Inbound Lane Allocation 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:** Configure deterministic A-H cargo-area directions and make warehouse 03 allocate dense-storage lanes by empty tray, raw-material identity, or project number without mixing incompatible cargo in one `group_coding` lane. + +**Architecture:** Resolve the exact inbound job and its details into a typed storage profile before selecting a location. For warehouse 03, serialize allocation by warehouse and roadway, evaluate completed stock plus active/reserved inbound jobs for every lane, choose a compatible existing lane before a new empty lane (empty trays use lane-level pseudo-random selection), and then choose the next slot using the configured cargo-area direction. Other warehouses retain the current location selection behavior. + +**Tech Stack:** Java 17, Spring Boot, MyBatis XML, SQL Server application locks and CTEs, JUnit 5, Mockito, AssertJ, Maven. + +--- + +## File map + +- Create `ruoyi-admin/src/main/java/com/ruoyi/in/job/domain/InboundStorageProfile.java`: immutable four-mode storage profile and mapper-parameter export. +- Modify `ruoyi-admin/src/main/java/com/ruoyi/in/job/domain/MydoJob.java`: transient profile fields used by occupied-location reallocation. +- Modify `ruoyi-admin/src/main/java/com/ruoyi/in/job/service/IMydoJobService.java`: expose profile resolution for the WCS location request path. +- Modify `ruoyi-admin/src/main/java/com/ruoyi/in/job/service/impl/MydoJobServiceImpl.java`: validate exact job details and resolve the four storage modes; attach the same profile during occupied-location reallocation. +- Modify `ruoyi-admin/src/main/java/com/ruoyi/pda/service/impl/PdaServiceImpl.java`: apply dense profile rules only to warehouse 03 and acquire the roadway allocation lock before choosing a new slot. +- Modify `ruoyi-admin/src/main/java/com/ruoyi/pda/mapper/PdaMapper.java`: add the roadway-lock mapper contract. +- Modify `ruoyi-admin/src/main/resources/mapper/pda/PdaMapper.xml`: add roadway serialization and lane-aware atomic selection. +- Modify `ruoyi-admin/src/main/resources/mapper/bas/BasCargoSpaceMapper.xml`: apply the same compatibility and direction rules to occupied-location reallocation. +- Modify `sql/20260714_cargo_area_direction.sql`: make the default warehouse 03 directions A/B/C/E ascending and D/F/G/H descending. +- Create `sql/20260714_warehouse_03_inbound_lane_allocation.sql`: idempotently apply the final direction configuration and supporting indexes to an already-migrated database. +- Modify focused JUnit tests under `ruoyi-admin/src/test/java/com/ruoyi/pda` and `ruoyi-admin/src/test/java/com/ruoyi/in/job`: prove profile validation, lock order, lane compatibility, randomization scope, and the reallocation guard. + +### Task 1: Define and validate the four inbound storage profiles + +**Files:** +- Create: `ruoyi-admin/src/main/java/com/ruoyi/in/job/domain/InboundStorageProfile.java` +- 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` +- Test: `ruoyi-admin/src/test/java/com/ruoyi/in/job/service/impl/MydoJobServiceImplTest.java` + +- [ ] **Step 1: Write failing service tests** + +Add tests that resolve these exact inputs: `KTP0001` to `EMPTY_TRAY`; purchase type `1` with blank project to `RAW_PRODUCT`; purchase type `1` with one nonblank project to `PROJECT`; production type `2` with one nonblank project to `PROJECT`. Add rejection tests for mixed product code/name in a projectless purchase tray, mixed project numbers, production without project number, missing job/details/stock-in bill, and unsupported inbound type. + +- [ ] **Step 2: Run the focused tests and verify failure** + +Run: `mvn -pl ruoyi-admin -Dtest=MydoJobServiceImplTest test` + +Expected: FAIL because `InboundStorageProfile` and `resolveInboundStorageProfile` do not exist. + +- [ ] **Step 3: Implement the profile value object and resolver** + +Define modes `EMPTY_TRAY`, `RAW_PRODUCT`, and `PROJECT`; preserve `inType`, `jobId`, trimmed `projectNo`, `productId`, and `productName`. Resolve the exact latest inbound job for the tray and warehouse, load all details by `jobId`, and use the referenced `in_stockin.in_type`. Treat empty trays by the `KTP0001` detail identity; require one exact `productId + productName` pair for projectless purchase; require one nonblank project for project purchase and production. + +- [ ] **Step 4: Run focused tests and verify pass** + +Run: `mvn -pl ruoyi-admin -Dtest=MydoJobServiceImplTest test` + +Expected: PASS. + +### Task 2: Serialize warehouse 03 lane allocation + +**Files:** +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/pda/mapper/PdaMapper.java` +- Modify: `ruoyi-admin/src/main/resources/mapper/pda/PdaMapper.xml` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/pda/service/impl/PdaServiceImpl.java` +- Test: `ruoyi-admin/src/test/java/com/ruoyi/pda/mapper/PdaMapperSqlTest.java` +- Test: `ruoyi-admin/src/test/java/com/ruoyi/pda/service/impl/PdaServiceImplTest.java` + +- [ ] **Step 1: Write failing lock and parameter tests** + +Assert that warehouse 03 resolves and forwards `v_job_id`, `v_storage_mode`, `v_project_no`, `v_product_id`, `v_product_name`, and `v_in_type`; that other warehouses do not require a profile; and that a second transaction-owned application lock uses warehouse plus roadway and is acquired only after the existing tray lock and locked-location lookup. + +- [ ] **Step 2: Run the focused tests and verify failure** + +Run: `mvn -pl ruoyi-admin -Dtest=PdaServiceImplTest,PdaMapperSqlTest test` + +Expected: FAIL because the profile parameters and roadway lock are absent. + +- [ ] **Step 3: Implement the roadway lock and service orchestration** + +Keep the existing warehouse+tray lock for idempotency. If no location is already locked and `warehouseId` is `03`, resolve the storage profile, add it to the selection map, then acquire `sp_getapplock` with resource `wms:inbound-roadway:`, `Exclusive`, `Transaction`, and a 10-second timeout. Do not change other warehouses' current selection inputs or behavior. + +- [ ] **Step 4: Run focused tests and verify pass** + +Run: `mvn -pl ruoyi-admin -Dtest=PdaServiceImplTest,PdaMapperSqlTest test` + +Expected: PASS. + +### Task 3: Select a compatible `group_coding` lane atomically + +**Files:** +- Modify: `ruoyi-admin/src/main/resources/mapper/pda/PdaMapper.xml` +- Test: `ruoyi-admin/src/test/java/com/ruoyi/pda/mapper/PdaMapperSqlTest.java` + +- [ ] **Step 1: Write failing SQL contract tests** + +Assert the warehouse 03 SQL requires nonblank `group_coding`, reads positive-quantity `bas_stock + bas_stock_detail`, reads active `mydo_job + mydo_job_detail`, recognizes status-3 reserved slots by tray even before the job location is written back, excludes the current job, and rejects unknown occupied slots. Assert exact compatibility by mode: only `KTP0001` for empty trays, only projectless purchase rows with equal trimmed product code and name for `RAW_PRODUCT`, and only equal nonblank project numbers for `PROJECT`. + +- [ ] **Step 2: Assert ranking and randomization rules** + +Require project and raw-material allocation to prefer a compatible occupied lane over an empty lane and consolidate into the most-filled compatible lane. Require empty trays to randomize once per lane using `CHECKSUM(group_coding, tray_code)`, never once per slot. Require the slot inside the selected lane to retain height priority and configured direction order. + +- [ ] **Step 3: Run the mapper test and verify failure** + +Run: `mvn -pl ruoyi-admin -Dtest=PdaMapperSqlTest test` + +Expected: FAIL because the current SQL ranks individual empty slots and never inspects lane contents. + +- [ ] **Step 4: Implement the SQL Server CTE-based candidate update** + +Build profiled occupancy from completed stock, active inbound jobs with assigned locations, and status-3 reservations joined by tray. Add an unknown profile for an occupied slot without traceable data so the lane fails closed. Aggregate by `group_coding`, retain only fully compatible lanes, rank lanes before slots, and perform one guarded `UPDATE ... WITH (UPDLOCK, READPAST, ROWLOCK)` from the chosen candidate. Keep the old candidate query under the non-03 branch. + +- [ ] **Step 5: Run the mapper test and verify pass** + +Run: `mvn -pl ruoyi-admin -Dtest=PdaMapperSqlTest test` + +Expected: PASS. + +### Task 4: Preserve lane rules during occupied-location reallocation + +**Files:** +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/in/job/domain/MydoJob.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/in/job/service/impl/MydoJobServiceImpl.java` +- Modify: `ruoyi-admin/src/main/resources/mapper/bas/BasCargoSpaceMapper.xml` +- Test: `ruoyi-admin/src/test/java/com/ruoyi/in/job/service/impl/MydoJobServiceImplTest.java` +- Test: `ruoyi-admin/src/test/java/com/ruoyi/bas/cargospace/mapper/BasCargoSpaceMapperSqlTest.java` + +- [ ] **Step 1: Write failing reallocation tests** + +Assert that `handleWcsPutOccupiedException` resolves the exact job's storage profile before marking the old location, attaches the profile fields to the mapper query, and aborts without changing location when the profile is invalid. Assert the reallocation SQL uses stock, active/reserved jobs, current-job exclusion, compatibility, and `group_coding` ranking. + +- [ ] **Step 2: Run focused tests and verify failure** + +Run: `mvn -pl ruoyi-admin -Dtest=MydoJobServiceImplTest,BasCargoSpaceMapperSqlTest test` + +Expected: FAIL because reallocation currently uses only tray type, height, and direction. + +- [ ] **Step 3: Reuse the profile and lane predicate** + +Resolve the exact job before the old location becomes status `5`, copy the profile to transient `MydoJob` fields, and make `selectAvailableInboundCargoSpaceInRoadway` use the same warehouse-03 lane-compatibility and ranking rules. Keep its existing behavior for other warehouses. + +- [ ] **Step 4: Run focused tests and verify pass** + +Run: `mvn -pl ruoyi-admin -Dtest=MydoJobServiceImplTest,BasCargoSpaceMapperSqlTest test` + +Expected: PASS. + +### Task 5: Apply deterministic A-H direction configuration + +**Files:** +- Modify: `sql/20260714_cargo_area_direction.sql` +- Create: `sql/20260714_warehouse_03_inbound_lane_allocation.sql` +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/bas/cargospace/mapper/CargoAreaDirectionMigrationSqlTest.java` + +- [ ] **Step 1: Write the failing direction migration test** + +Assert final values `A=1`, `B=1`, `C=1`, `D=2`, `E=1`, `F=2`, `G=2`, and `H=2`, with active state `1`, and assert the migration is idempotent. + +- [ ] **Step 2: Run the focused test and verify failure** + +Run: `mvn -pl ruoyi-admin -Dtest=CargoAreaDirectionMigrationSqlTest test` + +Expected: FAIL because all eight current defaults are direction `1`. + +- [ ] **Step 3: Implement and apply the migration** + +Use a `MERGE`/guarded update inside `TRY/CATCH` transaction handling. Preserve the existing table and unrelated warehouses. Add only supporting nonclustered indexes that are absent. Execute against the configured disposable/current schema and query the eight rows afterward. + +- [ ] **Step 4: Run the direction test and verify pass** + +Run: `mvn -pl ruoyi-admin -Dtest=CargoAreaDirectionMigrationSqlTest test` + +Expected: PASS. + +### Task 6: End-to-end verification and completion audit + +**Files:** +- Verify all files above; no additional production changes unless a failing check exposes a gap. + +- [ ] **Step 1: Run all backend tests** + +Run: `mvn test` + +Expected: BUILD SUCCESS with every test passing. + +- [ ] **Step 2: Run synthetic SQL business scenarios in a rollback transaction** + +Create isolated warehouse-03 fixture lanes and prove: empty tray chooses only empty/empty-tray lanes; projectless raw material rejects a different code or name; purchase project and production project accept the same project lane and reject another project; active jobs and status-3 reservations block incompatible allocations; each A-H area returns its configured first column. Roll back all fixture data. + +- [ ] **Step 3: Verify the deployed direction rows** + +Query `bas_cargo_area_config` for warehouse `03` ordered A-H and confirm `1,1,1,2,1,2,2,2`. + +- [ ] **Step 4: Audit every explicit requirement** + +Map each of the four user modes, the two inbound types, all eight directions, other-warehouse preservation, completed/in-flight occupancy, and concurrent allocation to a passing test or database result. Do not mark completion if any item lacks direct evidence. diff --git a/docs/superpowers/plans/2026-07-14-dense-wcs-pull-and-routing.md b/docs/superpowers/plans/2026-07-14-dense-wcs-pull-and-routing.md new file mode 100644 index 0000000..e6e4135 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-dense-wcs-pull-and-routing.md @@ -0,0 +1,158 @@ +# Dense WCS Pull and Warehouse Routing 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:** Keep warehouse 03 inbound jobs pending for WCS pull, prevent duplicate active tray jobs, route push-mode jobs to each warehouse's configured WCS, and remove inbound-point selection from PDA tray binding. + +**Architecture:** Warehouse 03 is the dense warehouse. PDA binding creates a status-2/phase-0 job without an entrance or outbound HTTP call; `POST /api/external/wcs/inbound/job` atomically claims that job by tray, writes the WCS-provided entrance and height, changes it to status-3/phase-1, and returns the stored job. Other warehouses keep push mode, but `WcsClient` resolves a warehouse-specific base URL from configuration. A SQL Server unique filtered index is the final concurrency-safe guarantee that `mydo_job.tray_code` is unique. + +**Tech Stack:** Java 17, Spring Boot, MyBatis XML, SQL Server, JUnit 5/Mockito/AssertJ, Vue 2 uni-app. + +--- + +## File map + +- Modify `ruoyi-admin/src/main/java/com/ruoyi/api/config/ExternalProp.java`: support WCS base URLs keyed by warehouse ID. +- Modify `ruoyi-admin/src/main/java/com/ruoyi/api/client/BaseExternalClient.java`: resolve an API URL against a supplied base URL. +- Modify `ruoyi-admin/src/main/java/com/ruoyi/api/client/WcsClient.java`: route job push calls by `job.warehouseId`. +- Create `ruoyi-admin/src/main/java/com/ruoyi/api/domain/WcsDenseInboundJobRequest.java`: tray barcode, entrance number, and cargo height request. +- Modify `ruoyi-admin/src/main/java/com/ruoyi/api/controller/ExternalApiController.java`: expose the dense WCS job-pull endpoint. +- Modify `ruoyi-admin/src/main/java/com/ruoyi/in/job/mapper/MydoJobMapper.java` and `ruoyi-admin/src/main/resources/mapper/in/MydoJobMapper.xml`: atomic dense-job claim and tray-existence contracts. +- Modify `ruoyi-admin/src/main/java/com/ruoyi/in/job/service/IMydoJobService.java` and `ruoyi-admin/src/main/java/com/ruoyi/in/job/service/impl/MydoJobServiceImpl.java`: claim/validate the dense job and give duplicate trays a business error. +- Modify `ruoyi-admin/src/main/java/com/ruoyi/pda/domain/BindTrayRequest.java`, `ruoyi-admin/src/main/java/com/ruoyi/pda/domain/AddInJobRequest.java`, and `ruoyi-admin/src/main/java/com/ruoyi/pda/service/impl/PdaServiceImpl.java`: make entrance optional for binding and branch dense pull mode from ordinary push mode. +- Modify `pda/pages/warehouse/inbound/bindTray.vue` and `pda/api/warehouse/inbound.js`: remove inbound-point loading, selection, validation, and submission. +- Modify `ruoyi-admin/src/main/resources/application.yml`: document independently configurable warehouse WCS base URLs. +- Create `sql/20260714_mydo_job_tray_unique_and_wcs_pull.sql`: fail clearly on existing duplicates and add the unique index. +- Add/modify focused tests under `ruoyi-admin/src/test/java/com/ruoyi/api`, `ruoyi-admin/src/test/java/com/ruoyi/in/job`, and `ruoyi-admin/src/test/java/com/ruoyi/pda`. + +### Task 1: Prove dense pending and station-free PDA binding + +- [ ] Add service tests with a warehouse-03 request whose `stationPointId` is null. Assert the generated job is initialized with `status="2"`, `executionPhase="0"`, blank `entranceId`, blank roadway/location, and `wcsClient.sendPutawayTask` is never called. +- [ ] Add the ordinary-warehouse counterpart. Stub `selectAvailableInboundRoadway` and assert the job is still pushed, while no station is occupied and no entrance is written. +- [ ] Run `mvn -pl ruoyi-admin -am -Dtest=PdaServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test`; expect the new assertions to fail before implementation. +- [ ] Implement a station-free branch equivalent to: + +```java +if (DENSE_WAREHOUSE_ID.equals(request.getWarehouseId())) { + job.setStatus("2"); + job.setExecutionPhase("0"); + return resultMsg + ";密集库作业已生成,等待WCS获取"; +} +job.setRoadwayId(String.valueOf(selectAvailableInboundRoadway( + request.getTrayCode(), request.getWarehouseId()).get("roadway_id"))); +return dispatchInboundJob(job, resultMsg); +``` + +- [ ] Remove station-point controls and `stationPointId` from the PDA payload, then re-run the focused test. + +### Task 2: Add concurrency-safe tray uniqueness + +- [ ] Add mapper/service tests asserting a nonblank tray already in `mydo_job` is rejected with `托盘已存在未完成作业` before insert/procedure execution. +- [ ] Add an idempotent SQL migration test requiring a duplicate preflight query and a unique filtered index on `mydo_job(tray_code)`. +- [ ] Implement `countMydoJobByTrayCode(String trayCode)` and call it at all application job-insert entry points touched by this flow. +- [ ] Create the migration with this invariant: + +```sql +IF EXISTS ( + SELECT tray_code FROM dbo.mydo_job + WHERE tray_code IS NOT NULL + GROUP BY tray_code HAVING COUNT_BIG(*) > 1 +) + THROW 51000, N'mydo_job存在重复托盘条码,请清理后重试', 1; + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'dbo.mydo_job') AND name = N'UX_mydo_job_tray_code') + CREATE UNIQUE NONCLUSTERED INDEX UX_mydo_job_tray_code + ON dbo.mydo_job(tray_code) WHERE tray_code IS NOT NULL; +``` + +- [ ] Run focused mapper/service/migration tests and verify pass. + +### Task 3: Route push-mode jobs by warehouse WCS address + +- [ ] Add `WcsClientTest` cases with distinct `01` and `02` base URLs and assert each job posts to its own URL. +- [ ] Extend configuration with `warehouseBaseUrls` and deterministic fallback to the existing WCS `baseUrl` when a warehouse has no override. +- [ ] Resolve job URLs as: + +```java +String url = config().getApiUrl(apiKey, job.getWarehouseId()); +return post(url, body, interfaceName, this::parseWcsAck, MsgTypeEnum.JSON); +``` + +- [ ] Configure `01`, `02`, and `03` with independent environment-variable overrides in `application.yml`, without committing credentials. +- [ ] Run `mvn -pl ruoyi-admin -am -Dtest=WcsClientTest -Dsurefire.failIfNoSpecifiedTests=false test` and verify pass. + +### Task 4: Add the dense WCS job-pull endpoint + +- [ ] Add controller/service tests for missing tray, entrance, unsupported height, unknown tray, non-03 warehouse, non-inbound job, successful claim, and idempotent same-value retry. +- [ ] Define the request fields and aliases: + +```java +private String trayCode; +private String entranceId; +private BigDecimal height; +``` + +- [ ] Add mapper CAS SQL that updates only warehouse 03 inbound jobs in pending state: + +```sql +UPDATE mydo_job +SET entrance_id = #{entranceId}, height = #{height}, status = '3', + execution_phase = '1', start_time = COALESCE(start_time, CONVERT(varchar(19), GETDATE(), 120)) +WHERE job_id = #{jobId} AND warehouse_id = '03' AND in_or_out < 20 + AND status = '2' AND execution_phase = '0'; +``` + +- [ ] Expose `POST /api/external/wcs/inbound/job`, invoke the transactional claim service, reload the persisted row, and return it in the established WCS `code/message/data` response. +- [ ] Run controller and service tests and verify pass. + +### Task 5: Full verification and completion audit + +- [ ] Run `mvn -pl ruoyi-admin -am test`; expect `BUILD SUCCESS`. +- [ ] Run the PDA build command available in `pda/package.json`; if no build script exists, run a static import/reference check proving the removed station API is no longer referenced by the bind page. +- [ ] Run `cd ruoyi-ui && npm run build:prod` if shared warehouse/config UI files were changed. +- [ ] Inspect the final diff and map each of the five numbered requirements to direct code plus test/migration evidence; do not mark complete if any item lacks evidence. + +### Task 6: Correct point selection scope to warehouse 03 only + +**Files:** +- Modify: `pda/pages/warehouse/inbound/bindTray.vue` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/pda/service/impl/PdaServiceImpl.java` +- Test: `ruoyi-admin/src/test/java/com/ruoyi/pda/service/impl/PdaServiceImplTest.java` + +- [ ] **Step 1: Add differentiated service tests** + +Assert warehouse `01`/`02` binding without `stationPointId` fails with `请选择入库点位`, while warehouse `03` succeeds without a station and never calls station-point services. + +- [ ] **Step 2: Restore conditional PDA selection** + +Use the exact dense-warehouse predicate and conditional submit rule: + +```javascript +isDenseWarehouse() { + return this.warehouseId === '03' +} + +if (!this.isDenseWarehouse && !this.stationPointId) { + this.$modal.msg('请选择入库点位') + return +} +``` + +Show/load/reset the station picker only when `warehouseId !== '03'`; submit `stationPointId` for ordinary warehouses and omit it for `03`. + +- [ ] **Step 3: Restore backend enforcement** + +In `bindTrayAndAddInJob`, reject a missing point for non-03 warehouses, pass the selected point into `AddInJobRequest`, and explicitly clear it for warehouse 03: + +```java +if (!DENSE_WAREHOUSE_ID.equals(request.getWarehouseId()) + && !StringUtils.hasText(request.getStationPointId())) { + throw new RuntimeException("请选择入库点位"); +} +addInJobRequest.setStationPointId(DENSE_WAREHOUSE_ID.equals(request.getWarehouseId()) + ? null : request.getStationPointId()); +``` + +- [ ] **Step 4: Verify corrected behavior** + +Run `mvn -pl ruoyi-admin -am -Dtest=PdaServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test`, compile the PDA SFC template/script, and run the full Maven test suite. Expected: warehouse 01/02 require points, warehouse 03 does not, and all tests pass. diff --git a/docs/superpowers/plans/2026-07-14-inbound-outbound-lift-conflict.md b/docs/superpowers/plans/2026-07-14-inbound-outbound-lift-conflict.md new file mode 100644 index 0000000..b474fd1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-inbound-outbound-lift-conflict.md @@ -0,0 +1,42 @@ +# Inbound/Outbound Lift Conflict Exclusion 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:** Prevent warehouse-03 inbound layer/location allocation from using a floor currently being retrieved by an executing outbound job targeting the same station as the inbound entrance. + +**Architecture:** Resolve blocked floors directly in SQL by joining the current inbound job (`v_job_id`/`jobId`) to executing outbound jobs where `outbound.exit_id = inbound.entrance_id`, then join the outbound source location to obtain `the_floor`. Apply the same exclusion predicate to first-stage layer planning, second-stage atomic location allocation, and occupied-location reallocation so no path can bypass the lift-conflict rule. + +**Tech Stack:** MyBatis XML, SQL Server CTEs, JUnit 5/AssertJ, Maven, rollback-only database scenarios. + +--- + +### Task 1: Prove the conflict-floor contract + +**Files:** +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/pda/mapper/PdaMapperSqlTest.java` +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/bas/cargospace/mapper/BasCargoSpaceMapperSqlTest.java` + +- [ ] Write failing SQL tests requiring `status = '3'`, outbound `in_or_out >= 20`, equal outbound `exit_id` and current inbound `entrance_id`, source cargo-space join, and `the_floor` exclusion in planner, final lock, and reallocation SQL. +- [ ] Run the two mapper test classes and confirm failure because no conflict-floor CTE exists. + +### Task 2: Exclude lift-conflict floors everywhere + +**Files:** +- Modify: `ruoyi-admin/src/main/resources/mapper/pda/PdaMapper.xml` +- Modify: `ruoyi-admin/src/main/resources/mapper/bas/BasCargoSpaceMapper.xml` + +- [ ] Add `lift_conflict_floors` before `available_slots` in both warehouse-03 allocation CTEs. +- [ ] Filter candidate slots with `NOT EXISTS` against the candidate floor. +- [ ] Add the equivalent CTE and predicate to warehouse-03 occupied-location reallocation. +- [ ] Run focused tests and confirm pass. + +### Task 3: Database and regression verification + +**Files:** +- Verify all files above. + +- [ ] In a rollback transaction, create an inbound job and an executing outbound job with the same port; prove the outbound source floor is unavailable while another layer remains selectable. +- [ ] Change the outbound status away from `3` and prove the floor becomes available again. +- [ ] Change the outbound exit to another port and prove the floor becomes available again. +- [ ] Run `mvn test` and require all tests to pass. +- [ ] Audit that first-stage planning, second-stage locking, and exception reallocation all enforce the rule before marking complete. diff --git a/docs/superpowers/plans/2026-07-14-project-no-traceability.md b/docs/superpowers/plans/2026-07-14-project-no-traceability.md new file mode 100644 index 0000000..7096a07 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-project-no-traceability.md @@ -0,0 +1,218 @@ +# Project Number Traceability 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 a standalone `project_no` field and preserve it from inbound document creation through inbound details, job details, and inventory master/detail records. + +**Architecture:** The inbound master form is the single input point. The backend copies the master value to every inbound detail, SQL Server procedures carry the detail value into job details and inventory rows, and Java/MyBatis models expose the value consistently as `projectNo`. Archive tables receive the same column because current completion procedures archive with `INSERT ... SELECT *`. + +**Tech Stack:** Vue 2 + Element UI, Spring Boot/Java 17, MyBatis XML, SQL Server, JUnit 5/Mockito/AssertJ + +--- + +### Task 1: Lock down the project-number propagation contract + +**Files:** +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/in/stockin/service/impl/InStockinServiceImplTest.java` +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/in/job/mapper/MydoJobMapperSqlTest.java` +- Create: `ruoyi-admin/src/test/java/com/ruoyi/in/stockin/mapper/ProjectNoMapperSqlTest.java` + +- [ ] **Step 1: Add a service test that verifies the master project number is copied to each inbound detail** + +```java +saveDTO.setProjectNo("PRJ-001"); +service.saveInStockinWithDetails(saveDTO); +assertThat(detailCaptor.getValue().getProjectNo()).isEqualTo("PRJ-001"); +``` + +- [ ] **Step 2: Add mapper/procedure text tests for all required columns and transfers** + +```java +assertThat(inStockinMapperXml).contains("project_no", "#{projectNo}"); +assertThat(jobDetailMapperXml).contains("project_no", "#{projectNo}"); +assertThat(stockMapperXml).contains("project_no", "#{projectNo}"); +assertThat(projectMigrationSql) + .contains("in_stockin", "in_stockin_detail", "mydo_job_detail") + .contains("bas_stock", "bas_stock_detail"); +``` + +- [ ] **Step 3: Run focused tests and verify they fail before implementation** + +Run: `mvn -pl ruoyi-admin -Dtest=InStockinServiceImplTest,ProjectNoMapperSqlTest,MydoJobMapperSqlTest test` + +Expected: FAIL because `projectNo` properties, SQL mappings, and migration do not exist yet. + +### Task 2: Add the database migration and procedure propagation + +**Files:** +- Create: `sql/20260714_project_no_traceability.sql` +- Modify: `sql/proc_add_in_job_trace.sql` +- Modify: `sql/bas_stock_master_detail.sql` + +- [ ] **Step 1: Add nullable project-number columns idempotently** + +```sql +IF COL_LENGTH(N'dbo.in_stockin', N'project_no') IS NULL + ALTER TABLE dbo.in_stockin ADD project_no nvarchar(80) NULL; +``` + +Repeat for `in_stockin_detail`, `in_stockin_finish`, `in_stockin_detail_finish`, `mydo_job_detail`, `mydo_job_detail_finish`, `bas_stock`, and `bas_stock_detail`. + +- [ ] **Step 2: Backfill existing traceable rows** + +```sql +UPDATE d +SET d.project_no = h.project_no +FROM dbo.in_stockin_detail d +JOIN dbo.in_stockin h ON h.stockin_id = d.stockin_id +WHERE d.project_no IS NULL AND h.project_no IS NOT NULL; +``` + +Backfill job details from inbound details, inventory details from job/inbound details, and inventory masters from their inventory details. + +- [ ] **Step 3: Update `proc_add_in_job` to copy `in_stockin_detail.project_no` into `mydo_job_detail.project_no`** + +```sql +SELECT ..., d.project_no +FROM dbo.in_tray_msg tm +LEFT JOIN dbo.in_stockin_detail d ON tm.stockin_detail_id = d.stockin_detail_id; + +INSERT INTO dbo.mydo_job_detail (..., project_no) +VALUES (..., @project_no); +``` + +- [ ] **Step 4: Update `proc_complete_in_job` to copy the job detail project number into inventory master and detail rows** + +```sql +INSERT INTO bas_stock (..., project_no) +VALUES (..., @project_no); + +INSERT INTO bas_stock_detail (..., project_no) +VALUES (..., @project_no); +``` + +- [ ] **Step 5: Keep baseline procedure scripts consistent with the deployable migration** + +Update the existing standalone procedure scripts with the same cursor variables, selected columns, and insert columns. + +### Task 3: Expose `projectNo` in backend entities and MyBatis mappings + +**Files:** +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/in/stockin/domain/InStockin.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/in/sotckindetail/domain/InStockinDetail.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/in/jobdetail/domain/MydoJobDetail.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/bas/stock/domain/BasStock.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/bas/stock/domain/BasStockDetail.java` +- Modify: `ruoyi-admin/src/main/resources/mapper/in/InStockinMapper.xml` +- Modify: `ruoyi-admin/src/main/resources/mapper/in/InStockinDetailMapper.xml` +- Modify: `ruoyi-admin/src/main/resources/mapper/in/MydoJobDetailMapper.xml` +- Modify: `ruoyi-admin/src/main/resources/mapper/bas/BasStockMapper.xml` +- Modify: `ruoyi-admin/src/main/resources/mapper/bas/BasStockDetailMapper.xml` + +- [ ] **Step 1: Add the Java property to all five domain models** + +```java +/** 项目号 */ +@Excel(name = "项目号") +private String projectNo; + +public String getProjectNo() { return projectNo; } +public void setProjectNo(String projectNo) { this.projectNo = projectNo; } +``` + +- [ ] **Step 2: Add `project_no` to every result map, select list, query filter, insert, and update section** + +```xml + +project_no, +#{projectNo}, +``` + +- [ ] **Step 3: Run mapper contract tests** + +Run: `mvn -pl ruoyi-admin -Dtest=ProjectNoMapperSqlTest test` + +Expected: PASS. + +### Task 4: Copy the master project number to inbound details and support the create form + +**Files:** +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/in/stockin/service/impl/InStockinServiceImpl.java` +- Modify: `ruoyi-ui/src/views/in/add/index.vue` + +- [ ] **Step 1: Enforce server-side master-to-detail copying** + +```java +detail.setStockinId(saveDTO.getStockinId()); +detail.setProjectNo(saveDTO.getProjectNo()); +``` + +- [ ] **Step 2: Add the project-number input to the main inbound form** + +```vue + + + +``` + +- [ ] **Step 3: Include `projectNo` in the form model and detail payload** + +```javascript +mainForm: { projectNo: null } +// detail DTO +projectNo: this.mainForm.projectNo +``` + +- [ ] **Step 4: Run the inbound service test** + +Run: `mvn -pl ruoyi-admin -Dtest=InStockinServiceImplTest test` + +Expected: PASS and the captured detail contains the master `projectNo`. + +### Task 5: Preserve project number when jobs are created from existing inventory + +**Files:** +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/out/location/service/impl/LocationOutboundServiceImpl.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/pd/stockpd/service/impl/PdStockpdServiceImpl.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/pda/service/impl/PdaServiceImpl.java` + +- [ ] **Step 1: Copy inventory-detail project numbers into outbound and inventory job details** + +```java +jobDetail.setProjectNo(stockDetail.getProjectNo()); +``` + +- [ ] **Step 2: Include `projectNo` in inventory material map queries used for return jobs and copy it into return-job details** + +```java +returnDetail.setProjectNo(mapString(material, "projectNo")); +``` + +- [ ] **Step 3: Run focused service tests** + +Run: `mvn -pl ruoyi-admin -Dtest=LocationOutboundServiceImplTest,PdStockpdServiceImplTest,PdaServiceImplTest test` + +Expected: PASS. + +### Task 6: Verify the end-to-end change + +**Files:** +- Verify: all files listed above + +- [ ] **Step 1: Run all backend tests** + +Run: `mvn -pl ruoyi-admin test` + +Expected: BUILD SUCCESS. + +- [ ] **Step 2: Build the frontend production bundle** + +Run: `cd ruoyi-ui && npm run build:prod` + +Expected: Build completes successfully. + +- [ ] **Step 3: Inspect the final diff for unrelated or overwritten work** + +Run: `git diff --check && git status --short` + +Expected: no whitespace errors; existing user changes remain intact. diff --git a/docs/superpowers/plans/2026-07-14-warehouse-03-cargo-spaces.md b/docs/superpowers/plans/2026-07-14-warehouse-03-cargo-spaces.md new file mode 100644 index 0000000..496d574 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-warehouse-03-cargo-spaces.md @@ -0,0 +1,107 @@ +# Warehouse 03 Cargo Spaces 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:** Create the five-floor warehouse 03 dense-storage layout shown in the supplied diagram, with stable status values and lane-level `group_coding`. + +**Architecture:** Add one safe, one-time SQL migration that seeds warehouse/roadway metadata, derives every valid coordinate from compact layout ranges, classifies equipment and mother-channel nodes, and inserts five floors inside a transaction. The migration refuses to overwrite existing warehouse 03 data and aborts unless all expected totals, status counts, coordinate uniqueness rules, and lane grouping rules pass. + +**Tech Stack:** SQL Server T-SQL, `dbo.bas_warehouse`, `dbo.bas_roadway`, `dbo.bas_cargo_space`, RuoYi dictionary tables. + +--- + +### Task 1: Add the warehouse 03 layout migration + +**Files:** +- Create: `sql/20260714_warehouse_03_cargo_spaces.sql` + +- [x] **Step 1: Encode the valid diagram footprint** + +Create an in-memory layout from these inclusive column ranges. `the_platoon` is the diagram's horizontal 排 coordinate and `the_column` is its vertical 列 coordinate. + +```sql +insert into @footprint(the_column, platoon_from, platoon_to) +values + (1,1,3),(2,1,3),(3,1,3), + (4,1,5),(4,9,13), + (5,1,7),(5,9,13), + (6,1,13), + (7,1,13),(8,1,13),(9,1,13),(10,1,13),(11,1,13), + (12,1,13),(13,1,13),(14,1,13),(15,1,13),(16,1,13), + (17,1,13),(18,1,13),(19,1,13),(20,1,13),(21,1,13), + (22,1,5),(22,9,13),(23,9,13),(24,9,13); +``` + +- [x] **Step 2: Classify all five floors** + +Use the eight-digit ID `03 + 排(2) + 列(2) + 层(2)`. Apply statuses in this precedence order: + +```sql +case + when the_platoon = 7 and the_column in (5,21) then '7' + when the_platoon = 7 and the_column in (6,20) then '8' + when the_column in (7,13,19) + or (the_platoon = 7 and the_column between 7 and 19) then '9' + else '1' +end +``` + +Set `this_cargo_area` to zones 1-8 for ordinary storage coordinates. Leave equipment and mother-channel nodes unzoned. + +- [x] **Step 3: Assign stable lane groups** + +For ordinary positions only, define a lane as the same warehouse, 排, floor, and one of the four storage bands `01-06`, `08-12`, `14-18`, or `20-25`. Use that lane's lowest existing cargo-space ID as `group_coding`; special nodes keep `group_coding = NULL`. + +```sql +min(cargo_space_id) over ( + partition by the_platoon, the_floor, channel_band +) +``` + +- [x] **Step 4: Seed metadata and insert atomically** + +Insert warehouse `03` as `密集库`, roadway `1` as `密集库巷道`, and dictionary values 7/8/9 when missing. Refuse to overwrite existing warehouse 03 cargo-space rows; insert all generated rows in one transaction. + +### Task 2: Verify the generated data + +**Files:** +- Verify: `sql/20260714_warehouse_03_cargo_spaces.sql` + +- [x] **Step 1: Run the migration against FTC25009-WMS** + +Run the SQL through the configured project SQL Server connection. Expected result: one committed transaction and `1295` warehouse 03 rows. + +- [x] **Step 2: Verify totals and status distribution** + +```sql +select status, count(*) +from dbo.bas_cargo_space +where warehouse_id = '03' +group by status; +``` + +Expected: status 1 = 1030, status 7 = 10, status 8 = 10, status 9 = 245. + +- [x] **Step 3: Verify IDs and lane grouping** + +```sql +select count(*) as invalid_ids +from dbo.bas_cargo_space +where warehouse_id = '03' + and cargo_space_id not like '03[0-9][0-9][0-9][0-9][0-9][0-9]'; + +select count(distinct group_coding) as lane_groups +from dbo.bas_cargo_space +where warehouse_id = '03' and status = '1'; +``` + +Expected: `invalid_ids = 0` and `lane_groups = 240` (48 physical lanes on each of five floors). + +- [ ] **Step 4: Commit** + +```bash +git add docs/superpowers/plans/2026-07-14-warehouse-03-cargo-spaces.md sql/20260714_warehouse_03_cargo_spaces.sql +git commit -m "feat: add warehouse 03 cargo spaces" +``` + +Only perform this step when the user explicitly requests a Git commit. diff --git a/docs/superpowers/plans/2026-07-14-wcs-two-stage-layer-allocation.md b/docs/superpowers/plans/2026-07-14-wcs-two-stage-layer-allocation.md new file mode 100644 index 0000000..9d4ccb4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-wcs-two-stage-layer-allocation.md @@ -0,0 +1,169 @@ +# WCS Two-Stage Layer Allocation 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:** Make warehouse 03 WCS inbound allocation select a viable layer on the first request without claiming a location, then atomically allocate a compatible location on that selected layer on the second request. + +**Architecture:** Extend the WCS request with ordered arrays for available layers and shuttle-current layers plus an optional explicit stage. The first stage serializes planning, evaluates the same inventory/job/lane predicates used by final allocation, reserves only capacity by atomically writing the selected layer into `mydo_job.roadway_id`, and returns without changing cargo-space state or job cargo-space fields. The second stage interprets warehouse-03 `roadway_id` as the selected floor, re-evaluates and atomically claims a location, while other warehouses retain their one-stage behavior. + +**Tech Stack:** Java 17, Spring Boot, Jackson, MyBatis XML, SQL Server CTEs/application locks, JUnit 5, Mockito, AssertJ, Maven. + +--- + +### Task 1: Define and validate the WCS layer-array contract + +**Files:** +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/api/domain/WcsInboundLocationRequest.java` +- Test: `ruoyi-admin/src/test/java/com/ruoyi/api/domain/WcsInboundLocationRequestTest.java` +- Test: `ruoyi-admin/src/test/java/com/ruoyi/api/controller/ExternalApiControllerWcsCodeTest.java` + +- [ ] **Step 1: Write failing request-contract tests** + +Assert typed properties `List availableLayers`, `List currentLayers`, and optional `Integer allocationStage`; cover JSON aliases for floor/layer naming. Add controller rejection tests for missing arrays, layers outside 1-5, empty available layers, and stage outside 1-2. + +- [ ] **Step 2: Run tests and verify failure** + +Run: `mvn -pl ruoyi-admin -am -Dtest=WcsInboundLocationRequestTest,ExternalApiControllerWcsCodeTest -Dsurefire.failIfNoSpecifiedTests=false test` + +Expected: FAIL because the array and stage properties do not exist. + +- [ ] **Step 3: Add the request fields and normalization** + +Use `availableLayers` as WCS order, place current layers that are also available first, remove duplicates while preserving order, then append remaining available layers. Require both array properties for warehouse 03; allow an empty current-layer array so the allocator can fall back to all available layers. Explicit stage 1 means layer planning and stage 2 means location allocation; when omitted, infer stage 1 from blank job `roadway_id` and stage 2 from a selected layer for backward compatibility. + +- [ ] **Step 4: Run focused tests and verify pass** + +Run the command from Step 2. + +Expected: PASS. + +### Task 2: Add first-stage layer planning without cargo-space mutation + +**Files:** +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/pda/service/PdaService.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/pda/service/impl/PdaServiceImpl.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/pda/mapper/PdaMapper.java` +- Modify: `ruoyi-admin/src/main/resources/mapper/pda/PdaMapper.xml` +- Test: `ruoyi-admin/src/test/java/com/ruoyi/pda/service/impl/PdaServiceImplTest.java` +- Test: `ruoyi-admin/src/test/java/com/ruoyi/pda/mapper/PdaMapperSqlTest.java` + +- [ ] **Step 1: Write failing service and SQL tests** + +Assert current-layer ordering, fallback to other available layers, a transaction-owned warehouse planning lock, and an atomic update that changes only `mydo_job.roadway_id` when cargo-space fields are blank. Assert first-stage candidate SQL reads positive inventory, assigned active jobs, status-3 reservations, unknown occupied locations, lane compatibility, height, configured direction, and pending layer reservations. + +- [ ] **Step 2: Run tests and verify failure** + +Run: `mvn -pl ruoyi-admin -am -Dtest=PdaServiceImplTest,PdaMapperSqlTest -Dsurefire.failIfNoSpecifiedTests=false test` + +Expected: FAIL because layer planning contracts are absent. + +- [ ] **Step 3: Implement transactional planning** + +Add `planInboundLayer(trayCode, warehouseId, jobId, orderedLayers, height)`. Acquire the existing tray lock, then a warehouse-03 layer-planning application lock. Resolve the existing four-mode storage profile, query each preferred layer using the shared dense-lane candidate CTE, subtract other active jobs whose selected layer is persisted but cargo-space ID is still blank, then atomically write only the selected layer to `roadway_id`. Return `roadway_id`/`selected_layer`; never update `bas_cargo_space`, job height, cargo-space ID, or cargo-space name. + +- [ ] **Step 4: Run focused tests and verify pass** + +Run the command from Step 2. + +Expected: PASS. + +### Task 3: Route the controller through two explicit stages + +**Files:** +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/api/controller/ExternalApiController.java` +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/api/controller/ExternalApiControllerWcsCodeTest.java` + +- [ ] **Step 1: Write failing controller flow tests** + +For stage 1, assert the controller calls planning, returns the selected layer, never calls `lockInboundCargoSpace`, never writes job height/cargo-space fields, and stage-1 retries return the same selected layer without locking. For stage 2, assert a selected layer is required, the layer must still be in `availableLayers`, only then call the atomic location lock and cargo-space CAS update. + +- [ ] **Step 2: Run the controller tests and verify failure** + +Run: `mvn -pl ruoyi-admin -am -Dtest=ExternalApiControllerWcsCodeTest -Dsurefire.failIfNoSpecifiedTests=false test` + +Expected: FAIL because the controller currently performs one-stage locking. + +- [ ] **Step 3: Implement stage routing** + +Keep current job lookup and status checks. Apply the two-stage flow only to warehouse 03. Preserve the original one-stage path for other warehouses. For an already assigned location, retain the existing idempotent response and height check. + +- [ ] **Step 4: Run the controller tests and verify pass** + +Run the command from Step 2. + +Expected: PASS. + +### Task 4: Interpret warehouse-03 roadway as floor during final allocation + +**Files:** +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/pda/service/impl/PdaServiceImpl.java` +- Modify: `ruoyi-admin/src/main/resources/mapper/pda/PdaMapper.xml` +- Modify: `ruoyi-admin/src/main/resources/mapper/bas/BasCargoSpaceMapper.xml` +- Modify: `ruoyi-admin/src/main/resources/mapper/in/MydoJobMapper.xml` +- Test: `ruoyi-admin/src/test/java/com/ruoyi/pda/mapper/PdaMapperSqlTest.java` +- Test: `ruoyi-admin/src/test/java/com/ruoyi/bas/cargospace/mapper/BasCargoSpaceMapperSqlTest.java` +- Test: `ruoyi-admin/src/test/java/com/ruoyi/in/job/mapper/MydoJobMapperSqlTest.java` + +- [ ] **Step 1: Write failing floor-semantics tests** + +Assert warehouse-03 candidate SQL uses `the_floor = TRY_CONVERT(... roadway_id)` rather than physical `bcs.roadway_id`; locked-location reuse compares the requested selected layer to `the_floor`; final job CAS preserves the selected layer; occupied-location reallocation applies the same floor predicate. + +- [ ] **Step 2: Run focused tests and verify failure** + +Run the three mapper test classes. + +Expected: FAIL because all current SQL treats roadway as the physical aisle. + +- [ ] **Step 3: Apply floor semantics and shared capacity checks** + +Make the read-only planner and atomic updater share the same dense-lane CTE and pending-reservation guard. Return `the_floor` from locked-location lookup. For warehouse 03 normalize the returned `roadway_id` to the selected layer so the existing job CAS keeps that layer. Update reallocation to filter by selected floor; retain physical-roadway behavior in non-03 branches. + +- [ ] **Step 4: Run mapper tests and verify pass** + +Run the command from Step 2. + +Expected: PASS. + +### Task 5: Initialize and migrate active warehouse-03 jobs to two-stage semantics + +**Files:** +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/pda/service/impl/PdaServiceImpl.java` +- Create: `sql/20260714_warehouse_03_two_stage_layer_allocation.sql` +- Test: `ruoyi-admin/src/test/java/com/ruoyi/pda/mapper/Warehouse03TwoStageMigrationSqlTest.java` + +- [ ] **Step 1: Write failing initialization and migration tests** + +Assert newly created normal and empty-tray warehouse-03 jobs have blank `roadway_id`, while other warehouses keep the station roadway. Assert the migration clears only active warehouse-03 inbound jobs with blank cargo-space IDs and leaves assigned/history/other-warehouse jobs unchanged. + +- [ ] **Step 2: Run focused tests and verify failure** + +Run the PDA service and migration tests. + +Expected: FAIL because job creation currently persists station roadway and no transition migration exists. + +- [ ] **Step 3: Implement initialization and idempotent migration** + +Write blank roadway for new warehouse-03 jobs. In an XACT_ABORT/TRY-CATCH transaction, clear stale physical roadway values only for active warehouse-03 inbound jobs without a cargo-space assignment, and add absent candidate-query indexes if needed. + +- [ ] **Step 4: Apply migration and verify affected rows** + +Execute the migration against the configured database, then query active warehouse-03 jobs to prove unassigned jobs have blank roadway and assigned jobs are unchanged. + +### Task 6: Full verification and completion audit + +**Files:** +- Verify all files above. + +- [ ] **Step 1: Run all backend tests** + +Run: `mvn test` + +Expected: BUILD SUCCESS with every test passing. + +- [ ] **Step 2: Run rollback-only database scenarios** + +Prove current-layer-first selection, fallback when the current layer has no compatible candidate, no cargo-space status change after stage 1, only-roadway job mutation after stage 1, stage-2 allocation on the persisted floor, pending-reservation capacity protection, and stock/assigned/reserved job compatibility. Roll back all fixtures. + +- [ ] **Step 3: Audit every explicit requirement** + +Map array input, current-layer priority, two request stages, first-stage no-location mutation, second-stage atomic allocation, inventory/job occupancy, and no layer overbooking to direct test or database evidence before marking complete. diff --git a/docs/superpowers/plans/2026-07-15-wcs-inventory-outbound-fixes.md b/docs/superpowers/plans/2026-07-15-wcs-inventory-outbound-fixes.md new file mode 100644 index 0000000..aba7ced --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-wcs-inventory-outbound-fixes.md @@ -0,0 +1,617 @@ +# WCS、库存追溯与出库状态约束 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:** 完成 WCS 请求作业响应阶段、03 仓入口就近货位、盘点库存追溯、库存明细界面和出库双状态约束六项需求。 + +**Architecture:** 保留领取后的数据库执行阶段与现有 SQL 原子锁,仅在专用控制器响应中生成阶段为 0 的副本。入口号沿控制器、PDA 服务和 Mapper 传递并参与软排序;盘点追溯沿原库存到回库作业明细再到新库存传递;出库候选列表与存储过程同时执行双状态约束。 + +**Tech Stack:** Java 17、Spring Boot、MyBatis、SQL Server、JUnit 5、Mockito、AssertJ、Vue 2、Element UI、Node.js 静态验证脚本。 + +## Global Constraints + +- 只有 `/api/external/wcs/inbound/job` 返回的 `MydoJob.executionPhase` 为 `"0"`;其他 WCS 接口正常返回实际阶段。 +- 数据库领取后的 `mydo_job.execution_phase` 继续为 `"1"`,不得破坏重复领取保护。 +- 入口区域是软优先,目标区无可用货位时必须回退其他区域。 +- `03-AU-01`、`03-AU-03` 优先 G、H;`03-AU-02`、`03-AU-04` 优先 A、B。 +- 不能覆盖或丢失当前工作区已有未提交改动。 +- 目标文件多数已有前置改动,实施任务不自动暂存或提交这些文件;每个任务以测试和差异审查作为检查点。 +- SQL 迁移必须幂等;无法可靠追溯的历史数据不得猜测回填。 + +--- + +### Task 1: 仅请求作业接口返回执行阶段 0 + +**Files:** +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/api/controller/ExternalApiControllerWcsCodeTest.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/api/controller/ExternalApiController.java` + +**Interfaces:** +- Consumes: `IMydoJobService.claimDenseInboundJob(String, String, BigDecimal)` 返回数据库真实 `MydoJob`。 +- Produces: `ExternalApiController.claimDenseInboundJob` 返回独立副本,副本阶段为 `"0"`。 + +- [ ] **Step 1: 写失败的控制器测试** + +在 `ExternalApiControllerWcsCodeTest` 增加: + +```java +@Test +void claimDenseInboundJobReturnsPhaseZeroWithoutChangingStoredPhase() { + WcsDenseInboundJobRequest request = new WcsDenseInboundJobRequest(); + request.setTrayCode("TP-PULL-01"); + request.setEntranceId("03-AU-01"); + request.setHeight(BigDecimal.ONE); + MydoJob claimed = denseInboundJob(null); + claimed.setExecutionPhase("1"); + when(mydoJobService.claimDenseInboundJob("TP-PULL-01", "03-AU-01", BigDecimal.ONE)) + .thenReturn(claimed); + + AjaxResult result = controller.claimDenseInboundJob(request); + + assertWcsSuccess(result); + MydoJob responseJob = (MydoJob) result.get(AjaxResult.DATA_TAG); + assertThat(responseJob).isNotSameAs(claimed); + assertThat(responseJob.getExecutionPhase()).isEqualTo("0"); + assertThat(claimed.getExecutionPhase()).isEqualTo("1"); +} +``` + +在既有货位请求成功测试中补充 `assertThat(((MydoJob) result.get(AjaxResult.DATA_TAG)).getExecutionPhase()).isEqualTo("1")`,证明其他接口不被归零。 + +- [ ] **Step 2: 运行测试并确认按预期失败** + +Run: + +```powershell +mvn -pl ruoyi-admin -am "-Dtest=ExternalApiControllerWcsCodeTest" -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: 新测试因响应对象仍是原对象且阶段为 `1` 而失败。 + +- [ ] **Step 3: 编写最小实现** + +在控制器导入 `org.springframework.beans.BeanUtils`,并把主动请求作业成功分支改为: + +```java +MydoJob claimed = mydoJobService.claimDenseInboundJob( + request.getTrayCode(), request.getEntranceId(), request.getHeight()); +return wcsClaimSuccess(claimed); +``` + +新增专用帮助方法,不改通用 `wcsSuccess`: + +```java +private AjaxResult wcsClaimSuccess(MydoJob claimed) { + if (claimed == null) { + return wcsError("未找到可领取入库作业"); + } + MydoJob response = new MydoJob(); + BeanUtils.copyProperties(claimed, response); + response.setExecutionPhase("0"); + return wcsSuccess(response); +} +``` + +- [ ] **Step 4: 运行测试并确认通过** + +运行 Step 2 命令。Expected: `ExternalApiControllerWcsCodeTest` 全部通过。 + +- [ ] **Step 5: 差异检查点** + +Run: `git diff --check -- ruoyi-admin/src/main/java/com/ruoyi/api/controller/ExternalApiController.java ruoyi-admin/src/test/java/com/ruoyi/api/controller/ExternalApiControllerWcsCodeTest.java` + +Expected: 无空白错误;不暂存当前文件,避免把既有改动带入提交。 + +--- + +### Task 2: 03 仓按入口软优先分配 A/B 或 G/H 区 + +**Files:** +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/pda/service/impl/PdaServiceImplTest.java` +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/pda/mapper/PdaMapperSqlTest.java` +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/bas/cargospace/mapper/BasCargoSpaceMapperSqlTest.java` +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/api/controller/ExternalApiControllerWcsCodeTest.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/pda/service/PdaService.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/pda/service/impl/PdaServiceImpl.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/api/controller/ExternalApiController.java` +- Modify: `ruoyi-admin/src/main/resources/mapper/pda/PdaMapper.xml` +- Modify: `ruoyi-admin/src/main/resources/mapper/bas/BasCargoSpaceMapper.xml` + +**Interfaces:** +- Produces: `planInboundLayer(..., String entranceId, List orderedLayers, BigDecimal height)`。 +- Produces: `lockInboundCargoSpace(..., String roadwayId, String entranceId, BigDecimal height)`。 +- Consumes: `MydoJob.entranceId` 由 WCS 请求作业时保存。 + +- [ ] **Step 1: 写入口参数和 SQL 排序失败测试** + +在 `PdaServiceImplTest` 的选层与锁位测试中捕获传给 `PdaMapper` 的 Map,并增加: + +```java +assertThat(captor.getValue()).containsEntry("v_entrance_id", "03-AU-01"); +``` + +调用签名使用: + +```java +service.planInboundLayer("TP01", "03", "RK01", "03-AU-01", List.of(1, 2), BigDecimal.ONE); +service.lockInboundCargoSpace("TP01", "03", "1", "03-AU-01", BigDecimal.ONE); +``` + +在 `PdaMapperSqlTest` 对 `selectAvailableInboundLayerCandidate` 和 `lockInboundCargoSpace` 的 03 仓分支分别断言包含: + +```java +assertThat(sql) + .contains("#{v_entrance_id}") + .contains("03-AU-01") + .contains("03-AU-03") + .contains("03-AU-02") + .contains("03-AU-04") + .contains("this_cargo_area in ('G', 'H')") + .contains("this_cargo_area in ('A', 'B')") + .contains("entrance_area_priority") + .contains("order by height_priority asc, entrance_area_priority asc"); +``` + +在 `BasCargoSpaceMapperSqlTest` 对异常重分配查询增加相同断言,但参数名为 `#{entranceId}`。 + +- [ ] **Step 2: 运行测试并确认失败** + +Run: + +```powershell +mvn -pl ruoyi-admin -am "-Dtest=PdaServiceImplTest,PdaMapperSqlTest,BasCargoSpaceMapperSqlTest,ExternalApiControllerWcsCodeTest" -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: 编译或断言失败,因为接口没有入口参数且 SQL 没有入口区域优先级。 + +- [ ] **Step 3: 沿调用链传入口号** + +将 `PdaService` 和实现签名改为: + +```java +Map planInboundLayer(String trayCode, String warehouseId, String jobId, + String entranceId, List orderedLayers, BigDecimal height); + +Map lockInboundCargoSpace(String trayCode, String warehouseId, String roadwayId, + String entranceId, BigDecimal height); +``` + +在 `PdaServiceImpl` 两个参数 Map 中写入: + +```java +params.put("v_entrance_id", entranceId); +lockMap.put("v_entrance_id", entranceId); +``` + +控制器调用传 `job.getEntranceId()`;同步更新所有测试和既有调用点。 + +- [ ] **Step 4: 在三条 SQL 路径加入软优先排序** + +在候选 CTE 中加入: + +```sql +case + when upper(ltrim(rtrim(isnull(#{v_entrance_id}, '')))) in ('03-AU-01', '03-AU-03') + then case when bcs.this_cargo_area in ('G', 'H') then 0 else 1 end + when upper(ltrim(rtrim(isnull(#{v_entrance_id}, '')))) in ('03-AU-02', '03-AU-04') + then case when bcs.this_cargo_area in ('A', 'B') then 0 else 1 end + else 0 +end as entrance_area_priority +``` + +在 `PdaMapper.xml` 的选层查询和锁位更新选择中,把排序开头统一为: + +```sql +order by height_priority asc, + entrance_area_priority asc, +``` + +`BasCargoSpaceMapper.xml` 使用同一表达式,将参数改为 `#{entranceId}`,并把异常重分配排序开头改成相同顺序。 + +- [ ] **Step 5: 运行测试并确认通过** + +运行 Step 2 命令。Expected: 四个测试类全部通过。 + +- [ ] **Step 6: 差异检查点** + +运行 `git diff --check` 覆盖本任务文件;确认没有把区域条件写入 `WHERE`,因此仍允许跨区回退。 + +--- + +### Task 3: 盘点回库记录并回填入库主单与入库类型 + +**Files:** +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/pda/service/impl/PdaServiceImplTest.java` +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/pda/mapper/PdaMapperSqlTest.java` +- Create: `ruoyi-admin/src/test/java/com/ruoyi/pda/mapper/InventoryInboundTraceMigrationSqlTest.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/pda/service/impl/PdaServiceImpl.java` +- Modify: `ruoyi-admin/src/main/resources/mapper/pda/PdaMapper.xml` +- Modify: `sql/bas_stock_master_detail.sql` +- Modify: `sql/20260714_project_no_traceability.sql` +- Create: `sql/20260715_inventory_inbound_trace.sql` + +**Interfaces:** +- Produces: `selectInventoryTrayMaterials` Map 的 `stockinId`、`inType`。 +- Produces: 盘点回库 `MydoJobDetail.stockinId`。 +- Produces: 新库存 `bas_stock.stockin_id`、`bas_stock.in_type`。 + +- [ ] **Step 1: 写盘点追溯失败测试** + +`PdaMapperSqlTest` 断言盘点物料查询包含: + +```java +assertThat(selectInventoryMaterials) + .contains("left join bas_stock bs on bs.stock_id = jd.stock_id") + .contains("bs.stockin_id as stockinId") + .contains("bs.in_type as inType"); +``` + +在 `PdaServiceImplTest` 的盘点确认成功用例中,让物料 Map 包含 `stockinId=RKD001`、`inType=1`,捕获插入的回库 `MydoJobDetail` 并断言: + +```java +assertThat(returnDetail.getStockinId()).isEqualTo("RKD001"); +``` + +`InventoryInboundTraceMigrationSqlTest` 读取新迁移,断言包含 `in_stockin_finish`、`bas_stock_detail_finish`、`bas_stock_finish`、`stockin_id`、`in_type`、`in_or_out in (10, 11)`,并检查没有无条件覆盖非空追溯字段。 + +- [ ] **Step 2: 运行测试并确认失败** + +Run: + +```powershell +mvn -pl ruoyi-admin -am "-Dtest=PdaServiceImplTest,PdaMapperSqlTest,InventoryInboundTraceMigrationSqlTest" -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: 查询字段、回库明细赋值和迁移文件缺失导致失败。 + +- [ ] **Step 3: 在盘点确认阶段保存原追溯** + +把查询改为: + +```sql +select jd.job_detail_id as jobDetailId, + jd.stock_id as stockId, + jd.stockin_detail_id as stockDetailId, + jd.in_or_out_d_id as stockpdDetailId, + jd.product_id as materialCode, + jd.product_name as materialName, + jd.lot_no as lotNo, + jd.project_no as projectNo, + bs.stockin_id as stockinId, + bs.in_type as inType, + jd.stock_quantity as stockQuantity, + jd.out_quantity as quantity +from mydo_job_detail jd +left join bas_stock bs on bs.stock_id = jd.stock_id +where jd.job_id = #{jobId} +order by jd.job_detail_id +``` + +创建回库明细时增加: + +```java +returnDetail.setStockinId(mapString(material, "stockinId")); +``` + +- [ ] **Step 4: 修正完成入库过程的类型恢复** + +在两个基线脚本的 `proc_complete_in_job` 中,把只查当前主单的类型查询替换为: + +```sql +SELECT TOP 1 @in_type = source.in_type +FROM ( + SELECT 1 AS source_order, current_bill.in_type + FROM dbo.in_stockin current_bill + WHERE current_bill.stockin_id = @stockin_id + UNION ALL + SELECT 2 AS source_order, history_bill.in_type + FROM dbo.in_stockin_finish history_bill + WHERE history_bill.stockin_id = @stockin_id + UNION ALL + SELECT 3 AS source_order, archived_stock.in_type + FROM dbo.bas_stock_detail_finish archived_detail + INNER JOIN dbo.bas_stock_finish archived_stock + ON archived_stock.stock_id = archived_detail.stock_id + WHERE archived_detail.detail_id = @stockin_detail_id +) source +WHERE NULLIF(LTRIM(RTRIM(source.in_type)), '') IS NOT NULL +ORDER BY source.source_order; +``` + +`sql/20260715_inventory_inbound_trace.sql` 使用 `CREATE OR ALTER PROCEDURE` 部署同一过程,并在过程定义前执行历史回填。 + +- [ ] **Step 5: 编写只回填唯一来源的历史数据 SQL** + +新迁移中的回填采用: + +```sql +;WITH trace_candidates AS ( + SELECT bs.stock_id AS new_stock_id, + archived_stock.stockin_id AS traced_stockin_id, + archived_stock.in_type AS archived_in_type, + current_bill.in_type AS current_in_type, + history_bill.in_type AS history_in_type + FROM dbo.bas_stock bs + INNER JOIN dbo.mydo_job_finish inbound_job + ON inbound_job.job_id = bs.job_id + AND inbound_job.in_or_out IN (10, 11) + INNER JOIN dbo.mydo_job_detail_finish inbound_detail + ON inbound_detail.job_id = inbound_job.job_id + INNER JOIN dbo.bas_stock_detail_finish archived_detail + ON archived_detail.detail_id = inbound_detail.stockin_detail_id + INNER JOIN dbo.bas_stock_finish archived_stock + ON archived_stock.stock_id = archived_detail.stock_id + LEFT JOIN dbo.in_stockin current_bill + ON current_bill.stockin_id = archived_stock.stockin_id + LEFT JOIN dbo.in_stockin_finish history_bill + ON history_bill.stockin_id = archived_stock.stockin_id +), unique_trace AS ( + SELECT new_stock_id, + MAX(traced_stockin_id) AS traced_stockin_id, + MAX(COALESCE(current_in_type, history_in_type, archived_in_type)) AS traced_in_type + FROM trace_candidates + GROUP BY new_stock_id + HAVING COUNT(DISTINCT ISNULL(traced_stockin_id, '')) <= 1 + AND COUNT(DISTINCT ISNULL(COALESCE(current_in_type, history_in_type, archived_in_type), '')) <= 1 +) +UPDATE target +SET target.stockin_id = COALESCE(NULLIF(target.stockin_id, ''), unique_trace.traced_stockin_id), + target.in_type = COALESCE(NULLIF(target.in_type, ''), unique_trace.traced_in_type) +FROM dbo.bas_stock target +INNER JOIN unique_trace ON unique_trace.new_stock_id = target.stock_id +WHERE (target.stockin_id IS NULL OR target.stockin_id = '' + OR target.in_type IS NULL OR target.in_type = ''); +``` + +- [ ] **Step 6: 运行测试并确认通过** + +运行 Step 2 命令。Expected: 三个测试类通过。 + +- [ ] **Step 7: 差异检查点** + +运行 `git diff --check`;人工核对迁移只填空值且歧义来源不回填。 + +--- + +### Task 4: 库存明细项目号、追溯字段只读和物料状态开关 + +**Files:** +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/bas/stock/service/impl/BasStockServiceImplTest.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/bas/stock/service/impl/BasStockServiceImpl.java` +- Modify: `ruoyi-admin/src/main/resources/mapper/bas/BasStockDetailMapper.xml` +- Modify: `ruoyi-ui/src/views/bas/stock/index.vue` +- Create: `scripts/verify-wcs-inventory-outbound-fixes.js` + +**Interfaces:** +- Consumes: `BasStockDetail.projectNo` 已由现有 Mapper 查询提供。 +- Produces: 追溯字段更新保持原值;开关仍提交字符串 `1`/`2`。 + +- [ ] **Step 1: 写后端和前端失败测试** + +在 `BasStockServiceImplTest` 增加: + +```java +@Test +void updateDetailPreservesTraceabilityIds() { + BasStockDetail stored = new BasStockDetail(); + stored.setDetailId("KC01-1"); + stored.setStockId("KC01"); + stored.setJobDetailId("JOB-ORIGINAL"); + stored.setStockinDetailId("IN-ORIGINAL"); + BasStockDetail update = new BasStockDetail(); + update.setDetailId("KC01-1"); + update.setStockId("KC01"); + update.setJobDetailId("JOB-TAMPERED"); + update.setStockinDetailId("IN-TAMPERED"); + when(basStockDetailMapper.selectBasStockDetailByDetailId("KC01-1")).thenReturn(stored); + when(basStockDetailMapper.updateBasStockDetail(any())).thenReturn(1); + + service.updateBasStockDetail(update); + + ArgumentCaptor captor = ArgumentCaptor.forClass(BasStockDetail.class); + verify(basStockDetailMapper).updateBasStockDetail(captor.capture()); + assertThat(captor.getValue().getJobDetailId()).isEqualTo("JOB-ORIGINAL"); + assertThat(captor.getValue().getStockinDetailId()).isEqualTo("IN-ORIGINAL"); +} +``` + +创建 Node 脚本,读取库存 Vue 文件并断言项目号列、两个 `readonly`、`el-switch`、`active-value="1"`、`inactive-value="2"`;读取 Mapper 更新段并断言不包含 `job_detail_id =` 与 `stockin_detail_id =`。 + +- [ ] **Step 2: 运行测试并确认失败** + +Run: + +```powershell +mvn -pl ruoyi-admin -am "-Dtest=BasStockServiceImplTest" -Dsurefire.failIfNoSpecifiedTests=false test +node scripts/verify-wcs-inventory-outbound-fixes.js +``` + +Expected: 服务仍接受篡改值,前端/Mapper 静态断言失败。 + +- [ ] **Step 3: 实现后端不可变保护** + +在更新服务调用 Mapper 前增加: + +```java +if (oldDetail != null) { + basStockDetail.setJobDetailId(oldDetail.getJobDetailId()); + basStockDetail.setStockinDetailId(oldDetail.getStockinDetailId()); +} +``` + +从 `updateBasStockDetail` SQL 删除: + +```xml +job_detail_id = #{jobDetailId}, +stockin_detail_id = #{stockinDetailId}, +``` + +- [ ] **Step 4: 实现库存明细界面** + +子表增加: + +```vue + +``` + +状态表单改为: + +```vue + +``` + +两个追溯输入框增加 `readonly`;`resetDetail()` 将 `productStatus` 默认设为 `'1'`。 + +- [ ] **Step 5: 运行测试并确认通过** + +运行 Step 2 两条命令。Expected: JUnit 和 Node 验证均通过。 + +- [ ] **Step 6: 差异检查点** + +运行 `git diff --check`;确认查询筛选区的物料状态仍保持下拉框,只有新增/修改明细弹窗改为开关。 + +--- + +### Task 5: 出库候选与分配过程限制库存/物料双正常 + +**Files:** +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/out/stockout/mapper/OutStockoutMapperSqlTest.java` +- Modify: `scripts/verify-wcs-inventory-outbound-fixes.js` +- Modify: `ruoyi-ui/src/views/out/create/index.vue` +- Modify: `ruoyi-ui/src/views/out/stockout/index.vue` +- Modify: `sql/out_stockout_flow.sql` +- Create: `sql/20260715_outbound_normal_stock_filter.sql` + +**Interfaces:** +- Consumes: `BasStockDetailMapper` 现有 `stockStatus`、`productStatus` 可选过滤参数。 +- Produces: 自动和手动分配 SQL均只选择 `1/1` 状态库存。 + +- [ ] **Step 1: 写失败测试** + +`OutStockoutMapperSqlTest` 读取 `sql/out_stockout_flow.sql`,分别截取 `proc_out_hand_allocation`、`proc_out_auto_allocation` 并断言: + +```java +assertThat(handProcedure) + .contains("bs.stock_status = '1'") + .contains("bsd.product_status = '1'"); +assertThat(autoProcedure) + .contains("bs.stock_status = '1'") + .contains("bsd.product_status = '1'"); +``` + +Node 脚本断言两个 Vue 文件的 `listStockDetail` 调用都同时包含: + +```javascript +stockStatus: '1', +productStatus: '1' +``` + +- [ ] **Step 2: 运行测试并确认失败** + +Run: + +```powershell +mvn -pl ruoyi-admin -am "-Dtest=OutStockoutMapperSqlTest" -Dsurefire.failIfNoSpecifiedTests=false test +node scripts/verify-wcs-inventory-outbound-fixes.js +``` + +Expected: 自动/手动过程或前端查询缺少物料状态条件导致失败。 + +- [ ] **Step 3: 实现前端候选过滤** + +出库创建页的 `stockQuery` 初始化增加: + +```javascript +stockStatus: '1', +productStatus: '1' +``` + +重置搜索时不清空这两个固定值。手动分配调用增加 `productStatus: '1'`,保留现有 `stockStatus: '1'`。 + +- [ ] **Step 4: 实现 SQL 双状态过滤** + +在手动过程和自动过程的候选 `WHERE` 中、可用数量条件之前增加: + +```sql +AND bs.stock_status = '1' +AND bsd.product_status = '1' +``` + +创建 `sql/20260715_outbound_normal_stock_filter.sql`,使用 `CREATE OR ALTER PROCEDURE` 部署与 `out_stockout_flow.sql` 相同的两个完整过程定义,保证增量环境生效。 + +- [ ] **Step 5: 运行测试并确认通过** + +运行 Step 2 两条命令。Expected: JUnit 与 Node 验证通过。 + +- [ ] **Step 6: 差异检查点** + +运行 `git diff --check`;确认过滤同时存在于 UI 和两个 SQL 过程,而不是只依赖页面。 + +--- + +### Task 6: 全量验证和需求审计 + +**Files:** +- Verify: all files changed in Tasks 1–5 +- Update if necessary: `scripts/verify-wcs-inventory-outbound-fixes.js` + +**Interfaces:** +- Consumes: 六项实现及其测试。 +- Produces: 可逐项追溯的完成证据。 + +- [ ] **Step 1: 运行专项后端测试** + +```powershell +mvn -pl ruoyi-admin -am "-Dtest=ExternalApiControllerWcsCodeTest,PdaServiceImplTest,PdaMapperSqlTest,BasCargoSpaceMapperSqlTest,InventoryInboundTraceMigrationSqlTest,BasStockServiceImplTest,OutStockoutMapperSqlTest" -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: 0 failures、0 errors。 + +- [ ] **Step 2: 运行前端/SQL静态需求验证** + +```powershell +node scripts/verify-wcs-inventory-outbound-fixes.js +``` + +Expected: 六项检查全部输出 PASS,进程退出码 0。 + +- [ ] **Step 3: 运行后端完整测试和打包** + +```powershell +mvn test +mvn clean package -DskipTests +``` + +Expected: 两条命令均 `BUILD SUCCESS`。 + +- [ ] **Step 4: 运行管理端生产构建** + +```powershell +cd ruoyi-ui +npm run build:prod +``` + +Expected: 退出码 0,无编译错误。 + +- [ ] **Step 5: 执行工作区质量检查** + +```powershell +git diff --check +git status --short +``` + +Expected: 无新增空白错误;状态列表只包含预期改动和实施前已有改动。 + +- [ ] **Step 6: 逐项完成审计** + +确认以下证据全部存在:请求作业专用响应测试;四入口两区域 SQL;盘点追溯查询、过程和回填迁移;项目号列;只读追溯字段与开关;UI 与两个分配过程双正常过滤。任何一项证据缺失都不得报告完成。 + +- [ ] **Step 7: 提交策略检查点** + +由于目标文件包含实施前未提交改动,本计划默认不暂存、不提交实现文件。最终交付列出本次修改文件和验证结果,由用户决定是否整体提交。 diff --git a/pda/pages/warehouse/inbound/bindTray.vue b/pda/pages/warehouse/inbound/bindTray.vue index 4da9fb1..42df533 100644 --- a/pda/pages/warehouse/inbound/bindTray.vue +++ b/pda/pages/warehouse/inbound/bindTray.vue @@ -23,12 +23,13 @@ 请先选择仓库和扫描托盘号 + 密集库入口号由 WCS 获取作业时分配 入库点位 - {{ selectedStationPointName || '请扫描入库点位' }} + {{ selectedStationPointName || '请选择入库点位' }} @@ -129,6 +130,29 @@ + + + + + + + INBOUND COMPLETE + 绑盘及作业生成完成 + {{ successDescription }} + + + 托盘条码 + {{ trayCode }} + + + 目标仓库 + {{ selectedWarehouseName || warehouseId }} + + + + + + @@ -162,8 +186,10 @@ - @@ -194,6 +220,9 @@ currentRemainQty: 0, currentQty: '', showQuantityPopup: false, + showSuccessDialog: false, + successDescription: '', + submitting: false, // 已完成绑盘并生成入库作业时保持页面状态,便于操作人员核对结果。 bindSuccess: false, // 当前主单下待绑盘子单,操作人员选择子单后再修改本次绑定数量。 @@ -217,8 +246,11 @@ var selected = this.stationPointOptions[this.stationPointIndex] return selected && selected.pointId === this.stationPointId ? (selected.pointName || selected.pointId) : '' }, - availableDetailList() { - return this.detailList.filter(function(detail) { + isDenseWarehouse() { + return this.warehouseId === '03' + }, + availableDetailList() { + return this.detailList.filter(function(detail) { return this.getAvailableRemainQty(detail) > 0 }.bind(this)) } @@ -228,10 +260,10 @@ if (this.bindSuccess) { return } - this.stationPointId = '' - this.stationPointName = '' - this.stationPointIndex = 0 - this.loadStationPointOptions() + this.clearStationPoint() + if (!this.isDenseWarehouse) { + this.loadStationPointOptions() + } } }, onLoad(options) { @@ -254,7 +286,7 @@ this.stationPointId = decodeURIComponent(options.stationPointId) } this.loadWarehouseOptions() - if (this.warehouseId) { + if (this.warehouseId && this.trayCode && !this.isDenseWarehouse) { this.loadStationPointOptions() } if (this.stockinId) { @@ -271,7 +303,7 @@ getWarehouseOptions().then(function(res) { that.warehouseOptions = res.data || res.rows || [] that.syncWarehouseIndex() - if (that.warehouseId) { + if (that.warehouseId && that.trayCode && !that.isDenseWarehouse) { that.loadStationPointOptions() } }).catch(function() { @@ -305,29 +337,30 @@ this.warehouseIndex = index this.warehouseId = selected.warehouseId this.warehouseName = selected.warehouseName + this.clearStationPoint() + if (!this.isDenseWarehouse) { + this.loadStationPointOptions() + } + }, + + clearStationPoint() { this.stationPointId = '' this.stationPointName = '' this.stationPointIndex = 0 - this.loadStationPointOptions() + this.stationPointOptions = [] }, loadStationPointOptions() { var that = this - if (!this.warehouseId || !this.trayCode) { - this.stationPointOptions = [] - this.stationPointId = '' - this.stationPointName = '' - this.stationPointIndex = 0 + if (this.isDenseWarehouse || !this.warehouseId || !this.trayCode) { + this.clearStationPoint() return } getInboundStationPointOptions(this.warehouseId, this.trayCode).then(function(res) { that.stationPointOptions = res.data || res.rows || [] that.syncStationPointIndex() }).catch(function() { - that.stationPointOptions = [] - that.stationPointId = '' - that.stationPointName = '' - that.stationPointIndex = 0 + that.clearStationPoint() }) }, @@ -525,7 +558,7 @@ this.$modal.msg('请选择仓库') return } - if (!this.stationPointId) { + if (!this.isDenseWarehouse && !this.stationPointId) { this.$modal.msg('请选择入库点位') return } @@ -536,7 +569,6 @@ var data = { trayCode: this.trayCode, warehouseId: this.warehouseId, - stationPointId: this.stationPointId, items: this.items.map(function(i) { return { stockinDetailId: i.stockinDetailId, @@ -545,31 +577,47 @@ } }) } + if (!this.isDenseWarehouse) { + data.stationPointId = this.stationPointId + } var that = this - this.$modal.confirm('是否确认提交绑盘信息?').then(function() { - return that.$modal.confirm('确认后将立即生成入库作业,是否继续?') - }).then(function() { + var confirmContent = this.isDenseWarehouse + ? '确认将当前 ' + this.items.length + ' 种物料绑定到托盘 ' + this.trayCode + ',并生成密集库待执行作业?' + : '确认将当前 ' + this.items.length + ' 种物料绑定到托盘 ' + this.trayCode + ',并生成入库作业?' + this.$modal.confirm(confirmContent, '确认绑盘').then(function() { + that.submitting = true return submitBindTrayAndAddInJob(data) }).then(function(res) { - that.$modal.msgSuccess(res.msg || '绑盘并生成入库作业成功') - that.resetBindPayload() + that.submitting = false + that.bindSuccess = true + that.successDescription = that.isDenseWarehouse + ? '密集库入库作业已生成,当前为待执行状态,等待 WCS 主动获取。' + : '托盘绑定完成,入库作业已生成并进入仓库执行流程。' + that.showSuccessDialog = true if (that.stockinId) { that.loadDetailList() } - }).catch(function() {}) + }).catch(function() { + that.submitting = false + }) + }, + + continueBinding() { + this.showSuccessDialog = false + this.resetBindPayload() }, resetBindPayload() { - this.stationPointId = '' - this.stationPointName = '' - this.stationPointIndex = 0 - this.stationPointOptions = [] + this.clearStationPoint() this.trayCode = '' this.barcode = '' this.currentMaterial = null this.currentRemainQty = 0 this.currentQty = '' this.showQuantityPopup = false + this.showSuccessDialog = false + this.successDescription = '' + this.submitting = false this.bindSuccess = false this.items = [] }, @@ -581,16 +629,16 @@ this.warehouseId = '' this.warehouseName = '' this.warehouseIndex = 0 - this.stationPointId = '' - this.stationPointName = '' - this.stationPointIndex = 0 - this.stationPointOptions = [] + this.clearStationPoint() this.trayCode = '' this.barcode = '' this.currentMaterial = null this.currentRemainQty = 0 this.currentQty = '' this.showQuantityPopup = false + this.showSuccessDialog = false + this.successDescription = '' + this.submitting = false this.bindSuccess = false this.detailList = [] this.items = [] @@ -1098,4 +1146,169 @@ color: #fff; background: #1890ff; } + + .success-dialog-mask { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: 44rpx; + background: rgba(14, 25, 38, 0.62); + backdrop-filter: blur(8rpx); + box-sizing: border-box; + animation: success-mask-in 180ms ease-out both; + } + + .success-dialog { + position: relative; + width: 100%; + max-width: 620rpx; + overflow: hidden; + padding: 54rpx 42rpx 38rpx; + border: 1rpx solid rgba(255, 255, 255, 0.88); + border-radius: 28rpx; + background: + linear-gradient(145deg, rgba(236, 252, 243, 0.92) 0, rgba(255, 255, 255, 0) 42%), + #fff; + box-shadow: 0 32rpx 90rpx rgba(5, 18, 31, 0.28); + box-sizing: border-box; + animation: success-card-in 260ms cubic-bezier(0.2, 0.8, 0.2, 1) both; + } + + .success-dialog::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 8rpx; + background: linear-gradient(90deg, #12a66a 0%, #46c68b 55%, #bcefd4 100%); + } + + .success-mark { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 112rpx; + height: 112rpx; + margin: 0 auto 28rpx; + border-radius: 50%; + background: #0f9f63; + box-shadow: 0 14rpx 30rpx rgba(15, 159, 99, 0.24); + } + + .success-mark::after { + content: ''; + position: absolute; + inset: -14rpx; + border: 2rpx solid rgba(15, 159, 99, 0.14); + border-radius: 50%; + } + + .success-check { + width: 48rpx; + height: 25rpx; + margin-top: -8rpx; + border-left: 8rpx solid #fff; + border-bottom: 8rpx solid #fff; + transform: rotate(-45deg); + } + + .success-kicker, + .success-title, + .success-description { + display: block; + text-align: center; + } + + .success-kicker { + margin-bottom: 12rpx; + color: #0f9f63; + font-size: 20rpx; + font-weight: 700; + letter-spacing: 3rpx; + } + + .success-title { + color: #132235; + font-size: 36rpx; + font-weight: 700; + line-height: 1.35; + } + + .success-description { + margin: 18rpx auto 30rpx; + color: #607083; + font-size: 26rpx; + line-height: 1.7; + } + + .success-summary { + margin-bottom: 30rpx; + padding: 6rpx 24rpx; + border: 1rpx solid #e6eee9; + border-radius: 16rpx; + background: rgba(247, 250, 248, 0.9); + } + + .success-summary-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24rpx; + padding: 18rpx 0; + } + + .success-summary-row + .success-summary-row { + border-top: 1rpx solid #e6eee9; + } + + .success-summary-label { + flex-shrink: 0; + color: #8692a0; + font-size: 24rpx; + } + + .success-summary-value { + color: #213247; + font-size: 25rpx; + font-weight: 600; + text-align: right; + word-break: break-all; + } + + .success-action { + height: 82rpx; + line-height: 82rpx; + border: 0; + border-radius: 14rpx; + color: #fff; + background: #132235; + box-shadow: 0 12rpx 24rpx rgba(19, 34, 53, 0.18); + font-size: 28rpx; + font-weight: 600; + } + + .success-action::after { + border: 0; + } + + @keyframes success-mask-in { + from { opacity: 0; } + to { opacity: 1; } + } + + @keyframes success-card-in { + from { + opacity: 0; + transform: translateY(28rpx) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/api/client/WcsClient.java b/ruoyi-admin/src/main/java/com/ruoyi/api/client/WcsClient.java index 80ec8cc..bce4dd8 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/api/client/WcsClient.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/api/client/WcsClient.java @@ -56,7 +56,7 @@ public class WcsClient extends BaseExternalClient { } try { String body = objectMapper.writeValueAsString(job); - return post(url("sendPutawayTask"), body, "WCS-下发入库任务", this::parseWcsAck, MsgTypeEnum.JSON); + return post(jobUrl("sendPutawayTask", job), body, "WCS-下发入库任务", this::parseWcsAck, MsgTypeEnum.JSON); } catch (Exception e) { return ApiResponse.fail("SERIALIZE_FAIL", "入库作业JSON报文生成失败: " + e.getMessage()); } @@ -86,7 +86,8 @@ public class WcsClient extends BaseExternalClient { } try { String body = objectMapper.writeValueAsString(job); - return post(url("sendPutawayTask"), body, "WCS-下发出库任务", this::parseWcsAck, MsgTypeEnum.JSON); + // 正式WCS作业对象仍使用统一接收入口;地址按出库作业所属仓库解析。 + return post(jobUrl("sendPutawayTask", job), body, "WCS-下发出库任务", this::parseWcsAck, MsgTypeEnum.JSON); } catch (Exception e) { return ApiResponse.fail("SERIALIZE_FAIL", "出库作业JSON报文生成失败: " + e.getMessage()); } @@ -156,6 +157,10 @@ public class WcsClient extends BaseExternalClient { return wcsConfig == null || wcsConfig.isMockEnabled(); } + private String jobUrl(String apiKey, MydoJob job) { + return config().getApiUrl(apiKey, job == null ? null : job.getWarehouseId()); + } + private String parseOkNg(String rawBody) { String[] parts = rawBody.trim().split("\\|", 2); if ("OK".equals(parts[0])) { diff --git a/ruoyi-admin/src/main/java/com/ruoyi/api/config/ExternalProp.java b/ruoyi-admin/src/main/java/com/ruoyi/api/config/ExternalProp.java index 532ebd2..6606c15 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/api/config/ExternalProp.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/api/config/ExternalProp.java @@ -35,6 +35,9 @@ public class ExternalProp { /** 该系统的接口路径配置,key=接口名,value=路径 */ private Map apis; + /** WCS按仓库配置的服务根地址,key=仓库编码,value=服务根地址。 */ + private Map warehouseBaseUrls; + public String getBaseUrl() { return baseUrl; } public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; } public String getToken() { return token; } @@ -47,6 +50,8 @@ public class ExternalProp { public void setMockEnabled(boolean mockEnabled) { this.mockEnabled = mockEnabled; } public Map getApis() { return apis; } public void setApis(Map apis) { this.apis = apis; } + public Map getWarehouseBaseUrls() { return warehouseBaseUrls; } + public void setWarehouseBaseUrls(Map warehouseBaseUrls) { this.warehouseBaseUrls = warehouseBaseUrls; } /** * 获取接口完整URL @@ -58,5 +63,25 @@ public class ExternalProp { } return baseUrl + apis.get(apiKey); } + + /** + * 获取指定仓库的接口完整URL。未配置仓库覆盖地址时兼容使用系统默认地址。 + */ + public String getApiUrl(String apiKey, String warehouseId) { + if (apis == null || !apis.containsKey(apiKey)) { + throw new IllegalStateException("未配置接口: " + apiKey); + } + String resolvedBaseUrl = baseUrl; + if (warehouseBaseUrls != null && warehouseId != null) { + String warehouseBaseUrl = warehouseBaseUrls.get(warehouseId.trim()); + if (warehouseBaseUrl != null && !warehouseBaseUrl.trim().isEmpty()) { + resolvedBaseUrl = warehouseBaseUrl.trim(); + } + } + if (resolvedBaseUrl == null || resolvedBaseUrl.trim().isEmpty()) { + throw new IllegalStateException("未配置WCS地址,warehouseId=" + warehouseId); + } + return resolvedBaseUrl.trim() + apis.get(apiKey); + } } } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/api/controller/ExternalApiController.java b/ruoyi-admin/src/main/java/com/ruoyi/api/controller/ExternalApiController.java index 6f99168..e484821 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/api/controller/ExternalApiController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/api/controller/ExternalApiController.java @@ -5,6 +5,7 @@ import com.ruoyi.api.client.MesClient; import com.ruoyi.api.client.WcsClient; import com.ruoyi.api.domain.WcsInboundCompleteRequest; import com.ruoyi.api.domain.WcsInboundLocationRequest; +import com.ruoyi.api.domain.WcsDenseInboundJobRequest; import com.ruoyi.api.domain.WcsJobExceptionRequest; import com.ruoyi.api.domain.WcsOutboundCompleteRequest; import com.ruoyi.api.domain.ApiResponse; @@ -15,8 +16,12 @@ import com.ruoyi.in.job.domain.MydoJob; import com.ruoyi.in.job.service.IMydoJobService; import com.ruoyi.pda.service.PdaService; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.BeanUtils; import org.springframework.web.bind.annotation.*; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -156,6 +161,11 @@ public class ExternalApiController extends BaseController { @ApiLog(interfaceName = "WCS-请求入库货位", sysCode = "WCS", direction = "INBOUND") @PostMapping("/wcs/inbound/location") public AjaxResult inboundLocation(@RequestBody WcsInboundLocationRequest request) { + BigDecimal requestedHeight = request == null ? null : request.getHeight(); + if (!isSupportedHeight(requestedHeight)) { + return wcsError("height必须为1或2"); + } + MydoJob job = null; if (request != null && request.getJobId() != null && !request.getJobId().trim().isEmpty()) { job = mydoJobService.selectMydoJobByJobId(request.getJobId().trim()); @@ -174,26 +184,120 @@ public class ExternalApiController extends BaseController { if (job == null) { return wcsError("未找到对应入库作业"); } - if (job.getCargoSpaceId() == null || job.getCargoSpaceId().trim().isEmpty()) { - Map cargoSpace; - try { - cargoSpace = pdaService.lockInboundCargoSpace(job.getTrayCode(), job.getWarehouseId(), job.getRoadwayId()); - } catch (RuntimeException e) { - return wcsError(e.getMessage()); + if (!isInboundLocationAssignableJob(job)) { + return wcsError("非法入库作业或作业状态不允许分配货位"); + } + if (job.getCargoSpaceId() != null && !job.getCargoSpaceId().trim().isEmpty()) { + if (!isSupportedHeight(job.getHeight())) { + return wcsError("已分配入库作业height无效"); } - job.setRoadwayId(String.valueOf(cargoSpace.get("roadway_id"))); - job.setCargoSpaceId(String.valueOf(cargoSpace.get("cargo_space_id"))); - job.setCargoSpaceName(String.valueOf(cargoSpace.get("cargo_space_name"))); - int updateRows = mydoJobService.updateMydoJob(job); - if (updateRows <= 0) { - pdaService.releaseInboundCargoSpace(job.getTrayCode(), job.getWarehouseId()); + if (job.getHeight().compareTo(requestedHeight) != 0) { + return wcsError("请求height与已分配作业不一致"); + } + return wcsSuccess(job); + } + + if ("03".equals(job.getWarehouseId())) { + String layerValidation = validateDenseLayerRequest(request); + if (layerValidation != null) { + return wcsError(layerValidation); + } + List orderedLayers = orderedDenseLayers(request); + Integer stage = request.getAllocationStage(); + if (stage == null) { + stage = trimToNull(job.getRoadwayId()) == null ? 1 : 2; + } + if (stage == 1) { + String selectedLayer = trimToNull(job.getRoadwayId()); + if (selectedLayer != null) { + return wcsSuccess(job); + } + Map planned; + try { + planned = pdaService.planInboundLayer(job.getTrayCode(), job.getWarehouseId(), job.getJobId(), + job.getEntranceId(), orderedLayers, requestedHeight); + } catch (RuntimeException e) { + return wcsError(e.getMessage()); + } + job.setRoadwayId(String.valueOf(planned.get("roadway_id"))); + return wcsSuccess(job); + } + + String selectedLayer = trimToNull(job.getRoadwayId()); + if (selectedLayer == null) { + return wcsError("第二阶段分配货位前必须先完成选层"); + } + Integer selectedLayerNumber = parseDenseLayer(selectedLayer); + if (selectedLayerNumber == null) { + return wcsError("作业已选层无效:" + selectedLayer); + } + if (!request.getAvailableLayers().contains(selectedLayerNumber)) { + return wcsError("已选层" + selectedLayer + "不在本次可用层中"); + } + } + Map cargoSpace; + try { + cargoSpace = "03".equals(job.getWarehouseId()) + ? pdaService.lockInboundCargoSpace(job.getTrayCode(), job.getWarehouseId(), job.getRoadwayId(), + job.getEntranceId(), requestedHeight) + : pdaService.lockInboundCargoSpace(job.getTrayCode(), job.getWarehouseId(), job.getRoadwayId(), + requestedHeight); + } catch (RuntimeException e) { + return wcsError(e.getMessage()); + } + job.setRoadwayId(String.valueOf(cargoSpace.get("roadway_id"))); + job.setCargoSpaceId(String.valueOf(cargoSpace.get("cargo_space_id"))); + job.setCargoSpaceName(String.valueOf(cargoSpace.get("cargo_space_name"))); + job.setHeight(requestedHeight); + int updateRows = mydoJobService.assignInboundCargoSpaceIfUnassigned(job); + if (updateRows <= 0) { + MydoJob assignedJob = mydoJobService.selectMydoJobByJobId(job.getJobId()); + if (assignedJob == null || assignedJob.getCargoSpaceId() == null + || assignedJob.getCargoSpaceId().trim().isEmpty()) { + if (!tryReleaseInboundCandidate(job)) { + return wcsError("入库候选货位释放失败"); + } return wcsError("入库货位回写作业失败"); } + if (!job.getCargoSpaceId().equals(assignedJob.getCargoSpaceId())) { + if (!tryReleaseInboundCandidate(job)) { + return wcsError("入库候选货位释放失败"); + } + } + if (!isInboundLocationAssignableJob(assignedJob)) { + return wcsError("非法入库作业或作业状态不允许分配货位"); + } + if (!isSupportedHeight(assignedJob.getHeight())) { + return wcsError("已分配入库作业height无效"); + } + if (assignedJob.getHeight().compareTo(requestedHeight) != 0) { + return wcsError("请求height与已分配作业不一致"); + } + return wcsSuccess(assignedJob); } return wcsSuccess(job); } + /** + * 密集库WCS主动领取待执行入库作业。WCS在此请求中提供入口号和货物高度, + * WMS原子写回作业后直接返回完整作业对象。 + */ + @ApiLog(interfaceName = "密集库WCS主动获取入库作业", sysCode = "WCS", direction = "INBOUND") + @PostMapping("/wcs/inbound/job") + public AjaxResult claimDenseInboundJob(@RequestBody WcsDenseInboundJobRequest request) { + if (request == null) { + return wcsError("请求体不能为空"); + } + try { + MydoJob claimed = mydoJobService.claimDenseInboundJob( + request.getTrayCode(), request.getEntranceId(), request.getHeight()); + return wcsClaimSuccess(claimed); + } catch (RuntimeException e) { + return wcsError(e.getMessage()); + } + } + /** * WCS 完成入库后回调 WMS。 *

@@ -277,6 +381,16 @@ public class ExternalApiController extends BaseController { return wcsResponse(0, "操作成功", data); } + private AjaxResult wcsClaimSuccess(MydoJob claimed) { + if (claimed == null) { + return wcsError("未找到可领取入库作业"); + } + MydoJob response = new MydoJob(); + BeanUtils.copyProperties(claimed, response); + response.setExecutionPhase("0"); + return wcsSuccess(response); + } + private AjaxResult wcsError(String message) { return wcsResponse(1, message, null); } @@ -350,6 +464,82 @@ public class ExternalApiController extends BaseController { return inbound ? job.getInOrOut() < 20 : job.getInOrOut() >= 20; } + private boolean isSupportedHeight(BigDecimal height) { + return height != null + && (height.compareTo(BigDecimal.ONE) == 0 + || height.compareTo(BigDecimal.valueOf(2)) == 0); + } + + private String validateDenseLayerRequest(WcsInboundLocationRequest request) { + Integer stage = request.getAllocationStage(); + if (stage != null && stage != 1 && stage != 2) { + return "allocationStage必须为1或2"; + } + if (request.getAvailableLayers() == null || request.getAvailableLayers().isEmpty()) { + return "availableLayers不能为空"; + } + if (request.getCurrentLayers() == null) { + return "currentLayers不能为空"; + } + for (Integer layer : request.getAvailableLayers()) { + if (!isDenseLayer(layer)) { + return "availableLayers层号必须为1到5"; + } + } + for (Integer layer : request.getCurrentLayers()) { + if (!isDenseLayer(layer)) { + return "currentLayers层号必须为1到5"; + } + } + return null; + } + + private List orderedDenseLayers(WcsInboundLocationRequest request) { + LinkedHashSet available = new LinkedHashSet<>(request.getAvailableLayers()); + LinkedHashSet ordered = new LinkedHashSet<>(); + for (Integer layer : request.getCurrentLayers()) { + if (available.contains(layer)) { + ordered.add(layer); + } + } + ordered.addAll(available); + return new ArrayList<>(ordered); + } + + private boolean isDenseLayer(Integer layer) { + return layer != null && layer >= 1 && layer <= 5; + } + + private Integer parseDenseLayer(String value) { + try { + int layer = Integer.parseInt(value); + return isDenseLayer(layer) ? layer : null; + } catch (NumberFormatException e) { + return null; + } + } + + private boolean isInboundLocationAssignableJob(MydoJob job) { + if (job == null || job.getInOrOut() == null || job.getInOrOut() >= 20) { + return false; + } + return "1".equals(job.getStatus()) + || "2".equals(job.getStatus()) + || "3".equals(job.getStatus()); + } + + private boolean tryReleaseInboundCandidate(MydoJob candidateJob) { + try { + pdaService.releaseInboundCargoSpace( + candidateJob.getTrayCode(), + candidateJob.getWarehouseId(), + candidateJob.getCargoSpaceId()); + return true; + } catch (RuntimeException e) { + return false; + } + } + private String trimToNull(String value) { if (value == null) { return null; diff --git a/ruoyi-admin/src/main/java/com/ruoyi/api/domain/WcsDenseInboundJobRequest.java b/ruoyi-admin/src/main/java/com/ruoyi/api/domain/WcsDenseInboundJobRequest.java new file mode 100644 index 0000000..0e34a59 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/api/domain/WcsDenseInboundJobRequest.java @@ -0,0 +1,43 @@ +package com.ruoyi.api.domain; + +import com.fasterxml.jackson.annotation.JsonAlias; + +import java.math.BigDecimal; + +/** + * 密集库WCS主动领取入库作业的请求。 + */ +public class WcsDenseInboundJobRequest { + @JsonAlias({"trayBarcode", "palletCode", "trayNo"}) + private String trayCode; + + @JsonAlias({"entryNo", "entranceNo", "inletNo"}) + private String entranceId; + + @JsonAlias({"cargoHeight", "goodsHeight"}) + private BigDecimal height; + + public String getTrayCode() { + return trayCode; + } + + public void setTrayCode(String trayCode) { + this.trayCode = trayCode; + } + + public String getEntranceId() { + return entranceId; + } + + public void setEntranceId(String entranceId) { + this.entranceId = entranceId; + } + + public BigDecimal getHeight() { + return height; + } + + public void setHeight(BigDecimal height) { + this.height = height; + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/api/domain/WcsInboundLocationRequest.java b/ruoyi-admin/src/main/java/com/ruoyi/api/domain/WcsInboundLocationRequest.java index 3096907..978c1c5 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/api/domain/WcsInboundLocationRequest.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/api/domain/WcsInboundLocationRequest.java @@ -1,5 +1,10 @@ package com.ruoyi.api.domain; +import com.fasterxml.jackson.annotation.JsonAlias; + +import java.math.BigDecimal; +import java.util.List; + /** * WCS 入库货位请求参数。 *

@@ -27,6 +32,23 @@ public class WcsInboundLocationRequest { */ private String warehouseId; + /** + * 货物高度层级。 + */ + private BigDecimal height; + + /** WCS当前可用层,按WCS给出的优先顺序传入。 */ + @JsonAlias({"availableFloors", "usableLayers"}) + private List availableLayers; + + /** 小车当前所在层;与可用层相交的层优先。 */ + @JsonAlias({"currentFloors", "shuttleLayers", "carLayers"}) + private List currentLayers; + + /** 1选层、2分配具体货位;为空时按作业是否已有roadway_id兼容推断。 */ + @JsonAlias({"requestStage", "stage"}) + private Integer allocationStage; + public String getJobId() { return jobId; } @@ -58,4 +80,36 @@ public class WcsInboundLocationRequest { public void setWarehouseId(String warehouseId) { this.warehouseId = warehouseId; } + + public BigDecimal getHeight() { + return height; + } + + public void setHeight(BigDecimal height) { + this.height = height; + } + + public List getAvailableLayers() { + return availableLayers; + } + + public void setAvailableLayers(List availableLayers) { + this.availableLayers = availableLayers; + } + + public List getCurrentLayers() { + return currentLayers; + } + + public void setCurrentLayers(List currentLayers) { + this.currentLayers = currentLayers; + } + + public Integer getAllocationStage() { + return allocationStage; + } + + public void setAllocationStage(Integer allocationStage) { + this.allocationStage = allocationStage; + } } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/bas/cargospace/mapper/BasCargoSpaceMapper.java b/ruoyi-admin/src/main/java/com/ruoyi/bas/cargospace/mapper/BasCargoSpaceMapper.java index cd47c3e..f26254f 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/bas/cargospace/mapper/BasCargoSpaceMapper.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/bas/cargospace/mapper/BasCargoSpaceMapper.java @@ -143,4 +143,12 @@ public interface BasCargoSpaceMapper * @return 可用货位 */ BasCargoSpace selectAvailableInboundCargoSpaceInRoadway(MydoJob job); + + /** + * 在货位仍为空闲且满足本次仓库、巷道和高度要求时原子锁定。 + * + * @param cargoSpace 候选货位及锁定条件 + * @return 更新行数 + */ + int lockAvailableInboundCargoSpace(BasCargoSpace cargoSpace); } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/bas/stock/domain/BasStock.java b/ruoyi-admin/src/main/java/com/ruoyi/bas/stock/domain/BasStock.java index f71df68..a7c66cb 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/bas/stock/domain/BasStock.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/bas/stock/domain/BasStock.java @@ -83,6 +83,10 @@ public class BasStock extends BaseEntity @Excel(name = "入库主单") private String stockinId; + /** 项目号 */ + @Excel(name = "项目号") + private String projectNo; + /** 入库类型 */ @Excel(name = "入库类型") private String inType; @@ -283,6 +287,16 @@ public class BasStock extends BaseEntity return stockinId; } + public String getProjectNo() + { + return projectNo; + } + + public void setProjectNo(String projectNo) + { + this.projectNo = projectNo; + } + public void setInType(String inType) { this.inType = inType; @@ -412,6 +426,7 @@ public class BasStock extends BaseEntity .append("outQuantity", getOutQuantity()) .append("stockinDetailId", getStockinDetailId()) .append("stockinId", getStockinId()) + .append("projectNo", getProjectNo()) .append("inType", getInType()) .append("warehouseId", getWarehouseId()) .append("warehouseName", getWarehouseName()) diff --git a/ruoyi-admin/src/main/java/com/ruoyi/bas/stock/domain/BasStockDetail.java b/ruoyi-admin/src/main/java/com/ruoyi/bas/stock/domain/BasStockDetail.java index 5c4c859..2680d94 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/bas/stock/domain/BasStockDetail.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/bas/stock/domain/BasStockDetail.java @@ -52,6 +52,10 @@ public class BasStockDetail extends BaseEntity @Excel(name = "入库子单id") private String stockinDetailId; + /** 项目号 */ + @Excel(name = "项目号") + private String projectNo; + /** 货位id */ @Excel(name = "货位id") private String cargoSpaceId; @@ -193,6 +197,16 @@ public class BasStockDetail extends BaseEntity this.stockinDetailId = stockinDetailId; } + public String getProjectNo() + { + return projectNo; + } + + public void setProjectNo(String projectNo) + { + this.projectNo = projectNo; + } + public String getCargoSpaceId() { return cargoSpaceId; diff --git a/ruoyi-admin/src/main/java/com/ruoyi/bas/stock/service/impl/BasStockServiceImpl.java b/ruoyi-admin/src/main/java/com/ruoyi/bas/stock/service/impl/BasStockServiceImpl.java index 85dc40e..a5cbb56 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/bas/stock/service/impl/BasStockServiceImpl.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/bas/stock/service/impl/BasStockServiceImpl.java @@ -219,6 +219,11 @@ public class BasStockServiceImpl implements IBasStockService public int updateBasStockDetail(BasStockDetail basStockDetail) { BasStockDetail oldDetail = basStockDetailMapper.selectBasStockDetailByDetailId(basStockDetail.getDetailId()); + if (oldDetail != null) + { + basStockDetail.setJobDetailId(oldDetail.getJobDetailId()); + basStockDetail.setStockinDetailId(oldDetail.getStockinDetailId()); + } basStockDetail.setUpdateTime(DateUtils.getNowDate()); int rows = basStockDetailMapper.updateBasStockDetail(basStockDetail); if (rows > 0) diff --git a/ruoyi-admin/src/main/java/com/ruoyi/in/job/domain/InboundStorageProfile.java b/ruoyi-admin/src/main/java/com/ruoyi/in/job/domain/InboundStorageProfile.java new file mode 100644 index 0000000..7b7a6af --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/in/job/domain/InboundStorageProfile.java @@ -0,0 +1,107 @@ +package com.ruoyi.in.job.domain; + +import java.util.Map; + +/** + * 03仓密集库入库通道存储画像。 + * + *

画像只描述同一通道允许混放的业务键,不参与数据库持久化。

+ */ +public final class InboundStorageProfile +{ + public static final String EMPTY_TRAY = "EMPTY_TRAY"; + public static final String RAW_PRODUCT = "RAW_PRODUCT"; + public static final String RAW_PROJECT = "RAW_PROJECT"; + public static final String SEMI_PROJECT = "SEMI_PROJECT"; + public static final String EMPTY_TRAY_PRODUCT_ID = "KTP0001"; + public static final String EMPTY_TRAY_PRODUCT_NAME = "空容器"; + + private final String jobId; + private final String storageMode; + private final String inType; + private final String projectNo; + private final String productId; + private final String productName; + + private InboundStorageProfile(String jobId, String storageMode, String inType, + String projectNo, String productId, String productName) + { + this.jobId = jobId; + this.storageMode = storageMode; + this.inType = inType; + this.projectNo = projectNo; + this.productId = productId; + this.productName = productName; + } + + public static InboundStorageProfile emptyTray(String jobId) + { + return new InboundStorageProfile(jobId, EMPTY_TRAY, null, null, + EMPTY_TRAY_PRODUCT_ID, EMPTY_TRAY_PRODUCT_NAME); + } + + public static InboundStorageProfile rawProduct(String jobId, String inType, + String productId, String productName) + { + return new InboundStorageProfile(jobId, RAW_PRODUCT, inType, null, productId, productName); + } + + public static InboundStorageProfile rawProject(String jobId, String inType, String projectNo) + { + return new InboundStorageProfile(jobId, RAW_PROJECT, inType, projectNo, null, null); + } + + public static InboundStorageProfile semiProject(String jobId, String inType, String projectNo) + { + return new InboundStorageProfile(jobId, SEMI_PROJECT, inType, projectNo, null, null); + } + + public void putInto(Map target) + { + target.put("v_job_id", jobId); + target.put("v_storage_mode", storageMode); + target.put("v_in_type", inType); + target.put("v_project_no", projectNo); + target.put("v_product_id", productId); + target.put("v_product_name", productName); + } + + public void applyTo(MydoJob job) + { + job.setStorageMode(storageMode); + job.setInType(inType); + job.setProjectNo(projectNo); + job.setProductId(productId); + job.setProductName(productName); + } + + public String getJobId() + { + return jobId; + } + + public String getStorageMode() + { + return storageMode; + } + + public String getInType() + { + return inType; + } + + public String getProjectNo() + { + return projectNo; + } + + public String getProductId() + { + return productId; + } + + public String getProductName() + { + return productName; + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/in/job/domain/MydoJob.java b/ruoyi-admin/src/main/java/com/ruoyi/in/job/domain/MydoJob.java index bfb06de..b6bda76 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/in/job/domain/MydoJob.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/in/job/domain/MydoJob.java @@ -125,6 +125,21 @@ public class MydoJob extends BaseEntity @Excel(name = "客户名称") private String customerName; + /** 03仓密集库通道存储模式(非持久化字段) */ + private String storageMode; + + /** 入库单类型(非持久化字段) */ + private String inType; + + /** 项目号(非持久化字段) */ + private String projectNo; + + /** 通道匹配物料编码(非持久化字段) */ + private String productId; + + /** 通道匹配物料名称(非持久化字段) */ + private String productName; + public void setJobId(String jobId) { this.jobId = jobId; @@ -425,6 +440,56 @@ public class MydoJob extends BaseEntity return customerName; } + public String getStorageMode() + { + return storageMode; + } + + public void setStorageMode(String storageMode) + { + this.storageMode = storageMode; + } + + public String getInType() + { + return inType; + } + + public void setInType(String inType) + { + this.inType = inType; + } + + 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; + } + @Override public String toString() { return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) @@ -459,6 +524,11 @@ public class MydoJob extends BaseEntity .append("supplierName", getSupplierName()) .append("customerId", getCustomerId()) .append("customerName", getCustomerName()) + .append("storageMode", getStorageMode()) + .append("inType", getInType()) + .append("projectNo", getProjectNo()) + .append("productId", getProductId()) + .append("productName", getProductName()) .toString(); } } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/in/job/mapper/MydoJobMapper.java b/ruoyi-admin/src/main/java/com/ruoyi/in/job/mapper/MydoJobMapper.java index 55d3fde..beefaa9 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/in/job/mapper/MydoJobMapper.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/in/job/mapper/MydoJobMapper.java @@ -22,6 +22,22 @@ public interface MydoJobMapper */ public MydoJob selectMydoJobByJobId(String jobId); + /** + * 串行化同一作业的放货有货重分配。 + * + * @param params 作业ID + * @return SQL Server 应用锁结果码 + */ + Integer acquirePutOccupiedJobLock(HashMap params); + + /** + * 串行化同一仓库、巷道候选池的放货有货重分配。 + * + * @param params 仓库和巷道 + * @return SQL Server 应用锁结果码 + */ + Integer acquirePutOccupiedRoadwayLock(HashMap params); + /** * 按托盘号查询最新入库作业 * @@ -30,6 +46,12 @@ public interface MydoJobMapper */ MydoJob selectLatestInMydoJobByTrayCode(String trayCode); + /** 查询活动作业中是否已存在托盘条码。 */ + int countMydoJobByTrayCode(String trayCode); + + /** 原子领取密集库待执行入库作业。 */ + int claimDenseInboundJob(HashMap params); + /** * 按任务号查询入库作业 * @@ -118,6 +140,22 @@ public interface MydoJobMapper */ public int updateMydoJob(MydoJob mydoJob); + /** + * 仅在作业尚未分配货位时原子回写入库货位。 + * + * @param mydoJob 待分配的入库作业货位信息 + * @return 更新行数 + */ + int assignInboundCargoSpaceIfUnassigned(MydoJob mydoJob); + + /** + * 仅在作业仍指向预期旧货位时回写重分配结果。 + * + * @param params 新旧货位及作业信息 + * @return 更新行数 + */ + int reassignInboundCargoSpaceIfCurrent(HashMap params); + /** * 删除作业信息 * @@ -172,6 +210,22 @@ public interface MydoJobMapper */ List selectInMydoJobFinishList(MydoJob mydoJob); + /** + * 查询盘点入库作业信息列表。 + * + * @param mydoJob 作业信息 + * @return 作业信息集合 + */ + List selectInventoryInMydoJobList(MydoJob mydoJob); + + /** + * 查询已完成的盘点入库作业信息列表。 + * + * @param mydoJob 作业信息 + * @return 作业信息集合 + */ + List selectInventoryInMydoJobFinishList(MydoJob mydoJob); + /** * 查询出库作业信息列表 * @@ -180,12 +234,28 @@ public interface MydoJobMapper */ List selectOutMydoJobFinishList(MydoJob mydoJob); + /** + * 查询盘点出库作业信息列表。 + * + * @param mydoJob 作业信息 + * @return 作业信息集合 + */ + List selectInventoryOutMydoJobList(MydoJob mydoJob); + /** * 查询出库作业信息列表 * @param mydoJob * @return */ List selectOutMydoJobList(MydoJob mydoJob); + + /** + * 查询已完成的盘点出库作业信息列表。 + * + * @param mydoJob 作业信息 + * @return 作业信息集合 + */ + List selectInventoryOutMydoJobFinishList(MydoJob mydoJob); /** * 查询入库作业信息列表 * @param param diff --git a/ruoyi-admin/src/main/java/com/ruoyi/in/job/service/IMydoJobService.java b/ruoyi-admin/src/main/java/com/ruoyi/in/job/service/IMydoJobService.java index 7375c7b..5396fd2 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/in/job/service/IMydoJobService.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/in/job/service/IMydoJobService.java @@ -1,8 +1,10 @@ package com.ruoyi.in.job.service; import java.util.List; +import java.math.BigDecimal; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.in.job.domain.MydoJob; +import com.ruoyi.in.job.domain.InboundStorageProfile; /** * 作业信息Service接口 @@ -28,6 +30,9 @@ public interface IMydoJobService */ MydoJob selectLatestInMydoJobByTrayCode(String trayCode); + /** WCS主动领取03仓入库作业,并写回入口号与货物高度。 */ + MydoJob claimDenseInboundJob(String trayCode, String entranceId, BigDecimal height); + /** * 按任务号查询入库作业 * @@ -53,6 +58,15 @@ public interface IMydoJobService */ MydoJob selectLatestInMydoJobByTrayCodeAndWarehouseId(String trayCode, String warehouseId); + /** + * 解析03仓密集库入库通道存储画像。 + * + * @param trayCode 托盘号 + * @param warehouseId 仓库ID + * @return 通道存储画像 + */ + InboundStorageProfile resolveInboundStorageProfile(String trayCode, String warehouseId); + /** * 按托盘号和仓库查询最新出库作业。 * @@ -94,6 +108,14 @@ public interface IMydoJobService */ public int updateMydoJob(MydoJob mydoJob); + /** + * 仅在作业尚未分配货位时原子回写入库货位。 + * + * @param mydoJob 待分配的入库作业货位信息 + * @return 更新行数 + */ + int assignInboundCargoSpaceIfUnassigned(MydoJob mydoJob); + /** * 批量删除作业信息 * @@ -182,6 +204,22 @@ public interface IMydoJobService */ List selectInMydoJobFinishList(MydoJob mydoJob); + /** + * 查询盘点入库作业信息列表 + * + * @param mydoJob 作业信息 + * @return 作业信息集合 + */ + List selectInventoryInMydoJobList(MydoJob mydoJob); + + /** + * 查询已完成的盘点入库作业信息列表 + * + * @param mydoJob 作业信息 + * @return 作业信息集合 + */ + List selectInventoryInMydoJobFinishList(MydoJob mydoJob); + /** * 查询出库作业信息列表 * @param mydoJob @@ -195,4 +233,20 @@ public interface IMydoJobService * @return */ List selectOutMydoJobFinishList(MydoJob mydoJob); + + /** + * 查询盘点出库作业信息列表 + * + * @param mydoJob 作业信息 + * @return 作业信息集合 + */ + List selectInventoryOutMydoJobList(MydoJob mydoJob); + + /** + * 查询已完成的盘点出库作业信息列表 + * + * @param mydoJob 作业信息 + * @return 作业信息集合 + */ + List selectInventoryOutMydoJobFinishList(MydoJob mydoJob); } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/in/job/service/impl/MydoJobServiceImpl.java b/ruoyi-admin/src/main/java/com/ruoyi/in/job/service/impl/MydoJobServiceImpl.java index c8b286c..d58a134 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/in/job/service/impl/MydoJobServiceImpl.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/in/job/service/impl/MydoJobServiceImpl.java @@ -1,7 +1,10 @@ package com.ruoyi.in.job.service.impl; +import java.math.BigDecimal; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; import com.ruoyi.api.client.WcsClient; import com.ruoyi.api.domain.ApiResponse; @@ -22,9 +25,13 @@ import com.ruoyi.out.stockout.mapper.OutStockoutMapper; import com.ruoyi.out.stockoutdetail.mapper.OutStockoutDetailMapper; import com.ruoyi.pd.diff.mapper.PdDifferenceRecordMapper; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import com.ruoyi.in.job.mapper.MydoJobMapper; import com.ruoyi.in.job.domain.MydoJob; +import com.ruoyi.in.job.domain.InboundStorageProfile; +import com.ruoyi.in.jobdetail.domain.MydoJobDetail; +import com.ruoyi.in.stockin.domain.InStockin; import com.ruoyi.in.job.service.IMydoJobService; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.interceptor.TransactionAspectSupport; @@ -94,6 +101,52 @@ public class MydoJobServiceImpl implements IMydoJobService return mydoJobMapper.selectLatestInMydoJobByTrayCode(trayCode); } + @Override + @Transactional(rollbackFor = Exception.class) + public MydoJob claimDenseInboundJob(String trayCode, String entranceId, BigDecimal height) + { + if (!StringUtils.hasText(trayCode) || !StringUtils.hasText(entranceId)) { + throw new RuntimeException("托盘条码和入口号不能为空"); + } + if (height == null || (height.compareTo(BigDecimal.ONE) != 0 + && height.compareTo(BigDecimal.valueOf(2)) != 0)) { + throw new RuntimeException("货物高度必须为1或2"); + } + MydoJob job = mydoJobMapper.selectLatestInMydoJobByTrayCode(trayCode.trim()); + if (job == null) { + throw new RuntimeException("未找到对应托盘入库作业"); + } + if (!"03".equals(job.getWarehouseId()) || job.getInOrOut() == null || job.getInOrOut() >= 20) { + throw new RuntimeException("托盘对应作业不是03仓入库作业"); + } + if ("3".equals(job.getStatus()) && "1".equals(job.getExecutionPhase())) { + if (!entranceId.trim().equals(job.getEntranceId()) + || job.getHeight() == null || job.getHeight().compareTo(height) != 0) { + throw new RuntimeException("托盘作业已被其他入口领取"); + } + return job; + } + if (!("1".equals(job.getStatus()) || "2".equals(job.getStatus())) + || !"0".equals(job.getExecutionPhase())) { + throw new RuntimeException("密集库作业当前不可领取"); + } + HashMap params = new HashMap<>(); + params.put("jobId", job.getJobId()); + params.put("entranceId", entranceId.trim()); + params.put("height", height); + params.put("startTime", DateUtils.getTime()); + if (mydoJobMapper.claimDenseInboundJob(params) <= 0) { + MydoJob current = mydoJobMapper.selectMydoJobByJobId(job.getJobId()); + if (current != null && "3".equals(current.getStatus()) && "1".equals(current.getExecutionPhase()) + && entranceId.trim().equals(current.getEntranceId()) + && current.getHeight() != null && current.getHeight().compareTo(height) == 0) { + return current; + } + throw new RuntimeException("密集库作业已被其他WCS领取"); + } + return mydoJobMapper.selectMydoJobByJobId(job.getJobId()); + } + /** * 按任务号查询入库作业。 *

@@ -128,6 +181,110 @@ public class MydoJobServiceImpl implements IMydoJobService return mydoJobMapper.selectLatestInMydoJobByTrayCodeAndWarehouseId(query); } + @Override + public InboundStorageProfile resolveInboundStorageProfile(String trayCode, String warehouseId) + { + if (StringUtils.isEmpty(trayCode) || StringUtils.isEmpty(warehouseId)) { + throw new RuntimeException("托盘号和仓库不能为空"); + } + MydoJob query = new MydoJob(); + query.setTrayCode(trayCode.trim()); + query.setWarehouseId(warehouseId.trim()); + MydoJob job = mydoJobMapper.selectLatestInMydoJobByTrayCodeAndWarehouseId(query); + if (job == null) { + throw new RuntimeException("未找到03仓入库作业,不能分配密集库货位"); + } + return resolveInboundStorageProfile(job); + } + + private InboundStorageProfile resolveInboundStorageProfile(MydoJob job) + { + MydoJobDetail detailQuery = new MydoJobDetail(); + detailQuery.setJobId(job.getJobId()); + List details = mydoJobDetailMapper.selectMydoJobDetailList(detailQuery); + if (details == null || details.isEmpty()) { + throw new RuntimeException("入库作业没有物料明细,不能分配密集库货位"); + } + + boolean emptyTrayJob = Long.valueOf(3L).equals(job.getInOrOut()); + boolean allEmptyTrayMaterial = details.stream() + .allMatch(detail -> InboundStorageProfile.EMPTY_TRAY_PRODUCT_ID.equals(trimToNull(detail.getProductId()))); + if (emptyTrayJob || allEmptyTrayMaterial) { + if (!allEmptyTrayMaterial) { + throw new RuntimeException("空托盘作业只能包含空容器物料KTP0001"); + } + return InboundStorageProfile.emptyTray(job.getJobId()); + } + + Set stockinIds = new LinkedHashSet<>(); + Set projects = new LinkedHashSet<>(); + boolean hasBlankProject = false; + for (MydoJobDetail detail : details) { + String stockinId = trimToNull(detail.getStockinId()); + if (stockinId != null) { + stockinIds.add(stockinId); + } + String projectNo = trimToNull(detail.getProjectNo()); + if (projectNo == null) { + hasBlankProject = true; + } else { + projects.add(projectNo); + } + } + if (stockinIds.size() != 1) { + throw new RuntimeException("同一托盘必须且只能关联一张入库单"); + } + if (projects.size() > 1 || (hasBlankProject && !projects.isEmpty())) { + throw new RuntimeException("同一托盘只能包含一个项目号,且项目号必须统一"); + } + + String stockinId = stockinIds.iterator().next(); + InStockin stockin = inStockinMapper.selectInStockinByStockinId(stockinId); + if (stockin == null) { + throw new RuntimeException("未找到入库单据,不能解析密集库分配规则"); + } + String inType = trimToNull(stockin.getInType()); + String projectNo = projects.isEmpty() ? null : projects.iterator().next(); + if ("1".equals(inType)) { + if (projectNo != null) { + return InboundStorageProfile.rawProject(job.getJobId(), inType, projectNo); + } + Set productKeys = new LinkedHashSet<>(); + String productId = null; + String productName = null; + for (MydoJobDetail detail : details) { + String detailProductId = trimToNull(detail.getProductId()); + String detailProductName = trimToNull(detail.getProductName()); + if (detailProductId == null || detailProductName == null) { + throw new RuntimeException("无项目号原材料必须填写物料编码和物料名称"); + } + productKeys.add(detailProductId + "\u0000" + detailProductName); + productId = detailProductId; + productName = detailProductName; + } + if (productKeys.size() != 1) { + throw new RuntimeException("无项目号原材料同一托盘只能包含一种物料编码和物料名称"); + } + return InboundStorageProfile.rawProduct(job.getJobId(), inType, productId, productName); + } + if ("2".equals(inType)) { + if (projectNo == null) { + throw new RuntimeException("生产入库必须填写项目号"); + } + return InboundStorageProfile.semiProject(job.getJobId(), inType, projectNo); + } + throw new RuntimeException("03仓密集库仅支持采购入库类型1或生产入库类型2"); + } + + private String trimToNull(String value) + { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + /** * 按托盘号和仓库查询最新出库作业。 */ @@ -208,8 +365,16 @@ public class MydoJobServiceImpl implements IMydoJobService @Override public int insertMydoJob(MydoJob mydoJob) { + if (mydoJob != null && StringUtils.hasText(mydoJob.getTrayCode()) + && mydoJobMapper.countMydoJobByTrayCode(mydoJob.getTrayCode().trim()) > 0) { + throw new RuntimeException("托盘已存在未完成作业"); + } mydoJob.setCreateTime(DateUtils.getNowDate()); - return mydoJobMapper.insertMydoJob(mydoJob); + try { + return mydoJobMapper.insertMydoJob(mydoJob); + } catch (DuplicateKeyException e) { + throw new RuntimeException("托盘已存在未完成作业", e); + } } /** @@ -224,6 +389,12 @@ public class MydoJobServiceImpl implements IMydoJobService return mydoJobMapper.updateMydoJob(mydoJob); } + @Override + public int assignInboundCargoSpaceIfUnassigned(MydoJob mydoJob) + { + return mydoJobMapper.assignInboundCargoSpaceIfUnassigned(mydoJob); + } + /** * 批量删除作业信息 * @@ -492,6 +663,12 @@ public class MydoJobServiceImpl implements IMydoJobService if (StringUtils.isEmpty(jobId)) { return AjaxResult.error("作业ID不能为空"); } + HashMap jobLockParams = new HashMap<>(); + jobLockParams.put("jobId", jobId); + Integer jobLockResult = mydoJobMapper.acquirePutOccupiedJobLock(jobLockParams); + if (jobLockResult == null || jobLockResult < 0) { + return AjaxResult.error("放货有货异常处理锁获取失败,结果码:" + jobLockResult); + } MydoJob job = mydoJobMapper.selectMydoJobByJobId(jobId); if (job == null) { return AjaxResult.error("入库作业不存在"); @@ -505,6 +682,25 @@ public class MydoJobServiceImpl implements IMydoJobService if (StringUtils.isEmpty(job.getWarehouseId()) || StringUtils.isEmpty(job.getRoadwayId())) { return AjaxResult.error("入库作业仓库或巷道不能为空"); } + if (!isSupportedCargoHeight(job.getHeight())) { + return AjaxResult.error("入库作业height必须为1或2"); + } + HashMap roadwayLockParams = new HashMap<>(); + roadwayLockParams.put("warehouseId", job.getWarehouseId()); + roadwayLockParams.put("roadwayId", job.getRoadwayId()); + Integer roadwayLockResult = mydoJobMapper.acquirePutOccupiedRoadwayLock(roadwayLockParams); + if (roadwayLockResult == null || roadwayLockResult < 0) { + return AjaxResult.error("放货有货候选池锁获取失败,结果码:" + roadwayLockResult); + } + + if ("03".equals(job.getWarehouseId())) { + try { + InboundStorageProfile profile = resolveInboundStorageProfile(job); + profile.applyTo(job); + } catch (RuntimeException e) { + return AjaxResult.error(e.getMessage()); + } + } if (basCargoSpaceMapper.markInboundNeedCheck(job.getCargoSpaceId()) <= 0) { return rollbackError("入库作业原货位异常状态更新失败"); @@ -516,24 +712,37 @@ public class MydoJobServiceImpl implements IMydoJobService BasCargoSpace lockCargoSpace = new BasCargoSpace(); lockCargoSpace.setCargoSpaceId(cargoSpace.getCargoSpaceId()); - lockCargoSpace.setStatus("3"); + lockCargoSpace.setWarehouseId(job.getWarehouseId()); + lockCargoSpace.setRoadwayId(job.getRoadwayId()); + if ("03".equals(job.getWarehouseId())) { + lockCargoSpace.setTheFloor(Long.valueOf(job.getRoadwayId())); + } + lockCargoSpace.setTheHeight(job.getHeight().longValue()); lockCargoSpace.setTrayCode(job.getTrayCode()); - if (basCargoSpaceMapper.updateBasCargoSpace(lockCargoSpace) <= 0) { + if (basCargoSpaceMapper.lockAvailableInboundCargoSpace(lockCargoSpace) <= 0) { return rollbackError("新入库货位锁定失败"); } - MydoJob updateJob = new MydoJob(); - updateJob.setJobId(job.getJobId()); - updateJob.setRoadwayId(cargoSpace.getRoadwayId()); - updateJob.setCargoSpaceId(cargoSpace.getCargoSpaceId()); - updateJob.setCargoSpaceName(cargoSpace.getCargoSpaceName()); - if (mydoJobMapper.updateMydoJob(updateJob) <= 0) { + HashMap updateJobParams = new HashMap<>(); + updateJobParams.put("jobId", job.getJobId()); + updateJobParams.put("expectedCargoSpaceId", job.getCargoSpaceId()); + String assignedRoadway = "03".equals(job.getWarehouseId()) + ? job.getRoadwayId() : cargoSpace.getRoadwayId(); + updateJobParams.put("roadwayId", assignedRoadway); + updateJobParams.put("cargoSpaceId", cargoSpace.getCargoSpaceId()); + updateJobParams.put("cargoSpaceName", cargoSpace.getCargoSpaceName()); + if (mydoJobMapper.reassignInboundCargoSpaceIfCurrent(updateJobParams) <= 0) { return rollbackError("入库作业货位更新失败"); } MydoJob updatedJob = mydoJobMapper.selectMydoJobByJobId(jobId); if (updatedJob == null) { - updatedJob = updateJob; + updatedJob = new MydoJob(); + updatedJob.setJobId(job.getJobId()); + updatedJob.setRoadwayId(assignedRoadway); + updatedJob.setCargoSpaceId(cargoSpace.getCargoSpaceId()); + updatedJob.setCargoSpaceName(cargoSpace.getCargoSpaceName()); + updatedJob.setHeight(job.getHeight()); } return AjaxResult.success("放货有货异常处理完成", updatedJob); } @@ -555,6 +764,13 @@ public class MydoJobServiceImpl implements IMydoJobService return AjaxResult.error(message); } + private boolean isSupportedCargoHeight(BigDecimal height) + { + return height != null + && (height.compareTo(BigDecimal.ONE) == 0 + || height.compareTo(BigDecimal.valueOf(2)) == 0); + } + /** * 作业完成或作废后释放入口/出口点位,避免点位长期保持已分配状态。 */ @@ -612,6 +828,18 @@ public class MydoJobServiceImpl implements IMydoJobService return mydoJobMapper.selectInMydoJobFinishList(mydoJob); } + @Override + @WarehouseScope + public List selectInventoryInMydoJobList(MydoJob mydoJob) { + return mydoJobMapper.selectInventoryInMydoJobList(mydoJob); + } + + @Override + @WarehouseScope + public List selectInventoryInMydoJobFinishList(MydoJob mydoJob) { + return mydoJobMapper.selectInventoryInMydoJobFinishList(mydoJob); + } + @Override public List selectOutMydoJobList(MydoJob mydoJob) { return mydoJobMapper.selectOutMydoJobList(mydoJob); @@ -621,4 +849,14 @@ public class MydoJobServiceImpl implements IMydoJobService public List selectOutMydoJobFinishList(MydoJob mydoJob) { return mydoJobMapper.selectOutMydoJobFinishList(mydoJob); } + + @Override + public List selectInventoryOutMydoJobList(MydoJob mydoJob) { + return mydoJobMapper.selectInventoryOutMydoJobList(mydoJob); + } + + @Override + public List selectInventoryOutMydoJobFinishList(MydoJob mydoJob) { + return mydoJobMapper.selectInventoryOutMydoJobFinishList(mydoJob); + } } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/in/jobdetail/domain/MydoJobDetail.java b/ruoyi-admin/src/main/java/com/ruoyi/in/jobdetail/domain/MydoJobDetail.java index 5e6c4af..90f47d6 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/in/jobdetail/domain/MydoJobDetail.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/in/jobdetail/domain/MydoJobDetail.java @@ -85,6 +85,10 @@ public class MydoJobDetail extends BaseEntity @Excel(name = "供应商名称") private String supplierName; + /** 项目号 */ + @Excel(name = "项目号") + private String projectNo; + private String status; public String getStatus() { @@ -275,6 +279,16 @@ public class MydoJobDetail extends BaseEntity return supplierName; } + public String getProjectNo() + { + return projectNo; + } + + public void setProjectNo(String projectNo) + { + this.projectNo = projectNo; + } + @Override public String toString() { return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) @@ -296,6 +310,7 @@ public class MydoJobDetail extends BaseEntity .append("customerName", getCustomerName()) .append("supplierId", getSupplierId()) .append("supplierName", getSupplierName()) + .append("projectNo", getProjectNo()) .toString(); } } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/in/sotckindetail/domain/InStockinDetail.java b/ruoyi-admin/src/main/java/com/ruoyi/in/sotckindetail/domain/InStockinDetail.java index 0389e92..f447300 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/in/sotckindetail/domain/InStockinDetail.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/in/sotckindetail/domain/InStockinDetail.java @@ -92,6 +92,10 @@ public class InStockinDetail extends BaseEntity @Excel(name = "备注") private String remar; + /** 项目号 */ + @Excel(name = "项目号") + private String projectNo; + private String status; public String getStatus() { @@ -102,6 +106,16 @@ public class InStockinDetail extends BaseEntity this.status = status; } + public String getProjectNo() + { + return projectNo; + } + + public void setProjectNo(String projectNo) + { + this.projectNo = projectNo; + } + public void setStockinDetailId(String stockinDetailId) { this.stockinDetailId = stockinDetailId; @@ -314,6 +328,7 @@ public class InStockinDetail extends BaseEntity .append("inventoryStatus", getInventoryStatus()) .append("sourceDocN", getSourceDocN()) .append("remar", getRemar()) + .append("projectNo", getProjectNo()) .append("remark", getRemark()) .toString(); } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/in/stockin/domain/InStockin.java b/ruoyi-admin/src/main/java/com/ruoyi/in/stockin/domain/InStockin.java index 12cdddf..ffffdc5 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/in/stockin/domain/InStockin.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/in/stockin/domain/InStockin.java @@ -93,6 +93,10 @@ public class InStockin extends BaseEntity @Excel(name = "项目号/项目名称") private String projectNam; + /** 项目号 */ + @Excel(name = "项目号") + private String projectNo; + /** 供应商/客户 */ @Excel(name = "供应商/客户") private String counterpart; @@ -361,6 +365,16 @@ public class InStockin extends BaseEntity return projectNam; } + public void setProjectNo(String projectNo) + { + this.projectNo = projectNo; + } + + public String getProjectNo() + { + return projectNo; + } + public void setCounterpart(String counterpart) { this.counterpart = counterpart; @@ -573,6 +587,7 @@ public class InStockin extends BaseEntity .append("documentN", getDocumentN()) .append("documentDat", getDocumentDat()) .append("projectNam", getProjectNam()) + .append("projectNo", getProjectNo()) .append("counterpart", getCounterpart()) .append("receivingOr", getReceivingOr()) .append("purchasingOr", getPurchasingOr()) diff --git a/ruoyi-admin/src/main/java/com/ruoyi/in/stockin/service/impl/InStockinServiceImpl.java b/ruoyi-admin/src/main/java/com/ruoyi/in/stockin/service/impl/InStockinServiceImpl.java index 38f65b8..69cf841 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/in/stockin/service/impl/InStockinServiceImpl.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/in/stockin/service/impl/InStockinServiceImpl.java @@ -151,6 +151,8 @@ public class InStockinServiceImpl implements IInStockinService } // 关联主单ID detail.setStockinId(saveDTO.getStockinId()); + // 项目号以主单为唯一录入口,保存时统一下沉到每条子单。 + detail.setProjectNo(saveDTO.getProjectNo()); normalizeProductFields(detail); inStockinDetailMapper.insertInStockinDetail(detail); } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/kpi/controller/KpiController.java b/ruoyi-admin/src/main/java/com/ruoyi/kpi/controller/KpiController.java index 49e0603..cbc0315 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/kpi/controller/KpiController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/kpi/controller/KpiController.java @@ -54,6 +54,15 @@ public class KpiController extends BaseController return AjaxResult.success(kpiService.getCargoSpacePlanData(warehouseId, thePlatoon)); } + /** + * 密集库货位平面图数据 + */ + @GetMapping("/denseCargoSpacePlan") + @PreAuthorize("@ss.hasPermi('kpi:cargo:space:plan')") + public AjaxResult getDenseCargoSpacePlan(String warehouseId) { + return AjaxResult.success(kpiService.getDenseCargoSpacePlanData(warehouseId)); + } + /** * 货位平面图-货位详情 */ diff --git a/ruoyi-admin/src/main/java/com/ruoyi/kpi/service/KpiService.java b/ruoyi-admin/src/main/java/com/ruoyi/kpi/service/KpiService.java index 774be63..952adcf 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/kpi/service/KpiService.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/kpi/service/KpiService.java @@ -52,6 +52,12 @@ public interface KpiService */ Map getCargoSpacePlanData(String warehouseId, String thePlatoon); + /** + * 查询密集库货位平面图数据 + * @return 包含楼层列表和完整平面坐标的Map + */ + Map getDenseCargoSpacePlanData(String warehouseId); + /** * 查询货位详情(热力图tooltip) * @return 包含 msg 的Map diff --git a/ruoyi-admin/src/main/java/com/ruoyi/kpi/service/impl/KpiServiceImpl.java b/ruoyi-admin/src/main/java/com/ruoyi/kpi/service/impl/KpiServiceImpl.java index b9cf1a6..706d20d 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/kpi/service/impl/KpiServiceImpl.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/kpi/service/impl/KpiServiceImpl.java @@ -180,13 +180,17 @@ public class KpiServiceImpl implements KpiService { @Override public Map getCargoSpacePlanData(String warehouseId, String thePlatoon) { - // 1. 查询该仓库+排的所有货位 + // 1. 查询该仓库+排的所有货位(托盘库按一排展示:横向列、纵向层) BasCargoSpace query = new BasCargoSpace(); query.setWarehouseId(warehouseId); if (StringUtils.isNotEmpty(thePlatoon)) { query.setThePlatoon(Long.parseLong(thePlatoon)); } - List spaces = basCargoSpaceMapper.selectBasCargoSpaceList(query); + List spaces = new ArrayList<>(basCargoSpaceMapper.selectBasCargoSpaceList(query)); + spaces.sort( + Comparator.comparing(BasCargoSpace::getTheFloor, Comparator.nullsLast(Long::compareTo)) + .thenComparing(BasCargoSpace::getTheColumn, Comparator.nullsLast(Long::compareTo)) + ); // 2. 提取去重的列和层,用TreeSet保持有序 TreeSet columnSet = new TreeSet<>(); @@ -200,7 +204,7 @@ public class KpiServiceImpl implements KpiService { } } - // 3. 构建列标签和层标签 + // 3. 构建列标签和层标签(兼容旧热力图) List columnLabels = new ArrayList<>(); for (Long col : columnSet) { columnLabels.add(col + "列"); @@ -218,8 +222,23 @@ public class KpiServiceImpl implements KpiService { List outAllotList = new ArrayList<>(); List inCheckList = new ArrayList<>(); List outCheckList = new ArrayList<>(); + List> locations = new ArrayList<>(); for (BasCargoSpace space : spaces) { + Map location = new LinkedHashMap<>(); + location.put("cargoSpaceId", space.getCargoSpaceId()); + location.put("cargoSpaceName", space.getCargoSpaceName()); + location.put("thePlatoon", space.getThePlatoon()); + location.put("theColumn", space.getTheColumn()); + location.put("theFloor", space.getTheFloor()); + location.put("status", space.getStatus()); + location.put("locked", space.getLocked()); + location.put("trayCode", space.getTrayCode()); + location.put("theTypeName", space.getTheTypeName()); + location.put("groupCoding", space.getGroupCoding()); + location.put("thisCargoArea", space.getThisCargoArea()); + locations.add(location); + if (space.getStatus() == null || space.getTheColumn() == null || space.getTheFloor() == null) { continue; } @@ -245,8 +264,11 @@ public class KpiServiceImpl implements KpiService { } } - // 5. 转成JSON字符串(前端用eval解析) - Map result = new HashMap<>(); + // 5. 返回结构化坐标 + 兼容旧热力图字段 + Map result = new LinkedHashMap<>(); + result.put("columns", new ArrayList<>(columnSet)); + result.put("floors", new ArrayList<>(floorSet)); + result.put("locations", locations); try { result.put("column", objectMapper.writeValueAsString(columnLabels)); result.put("floor", objectMapper.writeValueAsString(floorLabels)); @@ -263,9 +285,54 @@ public class KpiServiceImpl implements KpiService { return result; } + @Override + public Map getDenseCargoSpacePlanData(String warehouseId) { + Map result = new LinkedHashMap<>(); + if (StringUtils.isEmpty(warehouseId)) { + result.put("floors", Collections.emptyList()); + result.put("locations", Collections.emptyList()); + return result; + } + + BasCargoSpace query = new BasCargoSpace(); + query.setWarehouseId(warehouseId); + List spaces = new ArrayList<>(basCargoSpaceMapper.selectBasCargoSpaceList(query)); + spaces.sort( + Comparator.comparing(BasCargoSpace::getTheFloor, Comparator.nullsLast(Long::compareTo)) + .thenComparing(BasCargoSpace::getTheColumn, Comparator.nullsLast(Long::compareTo)) + .thenComparing(BasCargoSpace::getThePlatoon, Comparator.nullsLast(Long::compareTo)) + ); + + TreeSet floorSet = new TreeSet<>(); + List> locations = new ArrayList<>(); + for (BasCargoSpace space : spaces) { + if (space.getTheFloor() != null) { + floorSet.add(space.getTheFloor()); + } + + Map location = new LinkedHashMap<>(); + location.put("cargoSpaceId", space.getCargoSpaceId()); + location.put("cargoSpaceName", space.getCargoSpaceName()); + location.put("thePlatoon", space.getThePlatoon()); + location.put("theColumn", space.getTheColumn()); + location.put("theFloor", space.getTheFloor()); + location.put("status", space.getStatus()); + location.put("locked", space.getLocked()); + location.put("trayCode", space.getTrayCode()); + location.put("theTypeName", space.getTheTypeName()); + location.put("groupCoding", space.getGroupCoding()); + location.put("thisCargoArea", space.getThisCargoArea()); + locations.add(location); + } + + result.put("floors", new ArrayList<>(floorSet)); + result.put("locations", locations); + return result; + } + @Override public Map getCargoSpaceMag(String warehouseId, String thePlatoon, String theColumn, String theFloor) { - Map result = new HashMap<>(); + Map result = new LinkedHashMap<>(); // 1. 根据坐标定位货位 BasCargoSpace query = new BasCargoSpace(); @@ -283,41 +350,73 @@ public class KpiServiceImpl implements KpiService { if (spaces.isEmpty()) { result.put("msg", "该货位不存在"); + result.put("stocks", Collections.emptyList()); return result; } BasCargoSpace space = spaces.get(0); - StringBuilder msg = new StringBuilder(); - msg.append("货位: ").append(space.getCargoSpaceName() != null ? space.getCargoSpaceName() : ""); + String[] statusNames = {"", "空位", "满位", "入库分配", "出库分配", "入库需盘点", "出库需盘点"}; + int statusVal = 1; + try { + statusVal = Integer.parseInt(space.getStatus()); + } catch (NumberFormatException ignored) { + } + String statusName = statusVal < statusNames.length ? statusNames[statusVal] : "未知"; - if ("2".equals(space.getStatus())) { - // 满位 - 查库存详情 - BasStockDetail stockQuery = new BasStockDetail(); - stockQuery.setCargoSpaceId(space.getCargoSpaceId()); - List stocks = basStockDetailMapper.selectBasStockDetailList(stockQuery); - if (!stocks.isEmpty()) { - for (BasStockDetail stock : stocks) { - msg.append("
物料: ").append(stock.getProductName() != null ? stock.getProductName() : stock.getProductId()); - msg.append("
数量: ").append(stock.getQuantity()); - msg.append("
批次: ").append(stock.getLotNo() != null ? stock.getLotNo() : ""); - msg.append("
托盘: ").append(stock.getTrayCode() != null ? stock.getTrayCode() : ""); - } - } else { - msg.append("
无库存记录"); - } - } else { - // 非满位 - 显示状态 - String[] statusNames = {"", "空位", "满位", "入库分配", "出库分配", "入库需盘点", "出库需盘点"}; - int statusVal = 1; - try { - statusVal = Integer.parseInt(space.getStatus()); - } catch (NumberFormatException ignored) {} - msg.append("
状态: ").append(statusVal < statusNames.length ? statusNames[statusVal] : "未知"); - if ("1".equals(space.getLocked())) { - msg.append(" (已锁定)"); - } + result.put("cargoSpaceId", space.getCargoSpaceId()); + result.put("cargoSpaceName", space.getCargoSpaceName()); + result.put("status", space.getStatus()); + result.put("statusName", statusName); + result.put("locked", space.getLocked()); + result.put("trayCode", space.getTrayCode()); + result.put("thePlatoon", space.getThePlatoon()); + result.put("theColumn", space.getTheColumn()); + result.put("theFloor", space.getTheFloor()); + result.put("groupCoding", space.getGroupCoding()); + result.put("thisCargoArea", space.getThisCargoArea()); + + // 2. 联查货位库存明细(不限状态,便于分配中/盘点中也能查看) + BasStockDetail stockQuery = new BasStockDetail(); + stockQuery.setCargoSpaceId(space.getCargoSpaceId()); + List stocks = basStockDetailMapper.selectBasStockDetailList(stockQuery); + List> stockList = new ArrayList<>(); + StringBuilder msg = new StringBuilder(); + msg.append("货位: ").append(space.getCargoSpaceName() != null ? space.getCargoSpaceName() : space.getCargoSpaceId()); + msg.append("
状态: ").append(statusName); + if ("1".equals(space.getLocked())) { + msg.append(" (已锁定)"); } + if (stocks != null && !stocks.isEmpty()) { + for (BasStockDetail stock : stocks) { + Map item = new LinkedHashMap<>(); + item.put("productId", stock.getProductId()); + item.put("productName", stock.getProductName()); + item.put("quantity", stock.getQuantity()); + item.put("availableQuantity", stock.getAvailableQuantity()); + item.put("lotNo", stock.getLotNo()); + item.put("projectNo", stock.getProjectNo()); + item.put("trayCode", stock.getTrayCode()); + item.put("stockStatus", stock.getStockStatus()); + stockList.add(item); + + msg.append("
物料: ").append(stock.getProductName() != null ? stock.getProductName() : stock.getProductId()); + msg.append("
数量: ").append(stock.getQuantity()); + if (stock.getLotNo() != null && !stock.getLotNo().isEmpty()) { + msg.append("
批次: ").append(stock.getLotNo()); + } + if (stock.getProjectNo() != null && !stock.getProjectNo().isEmpty()) { + msg.append("
项目号: ").append(stock.getProjectNo()); + } + if (stock.getTrayCode() != null && !stock.getTrayCode().isEmpty()) { + msg.append("
托盘: ").append(stock.getTrayCode()); + } + } + } else { + msg.append("
无库存记录"); + } + + result.put("stocks", stockList); result.put("msg", msg.toString()); return result; } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/out/location/service/impl/LocationOutboundServiceImpl.java b/ruoyi-admin/src/main/java/com/ruoyi/out/location/service/impl/LocationOutboundServiceImpl.java index a49b4a3..2aff6b1 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/out/location/service/impl/LocationOutboundServiceImpl.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/out/location/service/impl/LocationOutboundServiceImpl.java @@ -282,6 +282,7 @@ public class LocationOutboundServiceImpl implements ILocationOutboundService jobDetail.setStockQuantity(availableQuantity); jobDetail.setOutQuantity(availableQuantity); jobDetail.setStockinDetailId(detail.getDetailId()); + jobDetail.setProjectNo(detail.getProjectNo()); jobDetail.setSupplierId(stock.getSupplierId()); jobDetail.setSupplierName(stock.getSupplierName()); return jobDetail; diff --git a/ruoyi-admin/src/main/java/com/ruoyi/pd/job/controller/PdInMydoJobController.java b/ruoyi-admin/src/main/java/com/ruoyi/pd/job/controller/PdInMydoJobController.java index e8a95dd..eb6d198 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/pd/job/controller/PdInMydoJobController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/pd/job/controller/PdInMydoJobController.java @@ -38,9 +38,9 @@ public class PdInMydoJobController extends BaseController startPage(); List list; if (StringUtils.isEmpty(mydoJob.getStatus()) || Convert.toInt(mydoJob.getStatus())<4){ - list = mydoJobService.selectOutMydoJobList(mydoJob); + list = mydoJobService.selectInventoryOutMydoJobList(mydoJob); }else { - list = mydoJobService.selectOutMydoJobFinishList(mydoJob); + list = mydoJobService.selectInventoryOutMydoJobFinishList(mydoJob); } return getDataTable(list); } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/pd/job/controller/PdOutMydoJobController.java b/ruoyi-admin/src/main/java/com/ruoyi/pd/job/controller/PdOutMydoJobController.java index 1831511..15e19ff 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/pd/job/controller/PdOutMydoJobController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/pd/job/controller/PdOutMydoJobController.java @@ -45,9 +45,9 @@ public class PdOutMydoJobController extends BaseController startPage(); List list; if (StringUtils.isEmpty(mydoJob.getStatus()) || Convert.toInt(mydoJob.getStatus())<4){ - list = mydoJobService.selectOutMydoJobList(mydoJob); + list = mydoJobService.selectInventoryInMydoJobList(mydoJob); }else { - list = mydoJobService.selectOutMydoJobFinishList(mydoJob); + list = mydoJobService.selectInventoryInMydoJobFinishList(mydoJob); } return getDataTable(list); } @@ -103,7 +103,7 @@ public class PdOutMydoJobController extends BaseController @PutMapping("/cancel/{jobId}") public AjaxResult cancel(@PathVariable("jobId") String jobId) { - return mydoJobService.cancelJob(jobId,"2"); + return mydoJobService.cancelJob(jobId,"1"); } /** @@ -114,6 +114,6 @@ public class PdOutMydoJobController extends BaseController @PutMapping("/complete/{jobId}") public AjaxResult complete(@PathVariable("jobId") String jobId) { - return mydoJobService.completeJob(jobId,"2"); + return mydoJobService.completeJob(jobId,"1"); } } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/pd/stockpd/service/impl/PdStockpdServiceImpl.java b/ruoyi-admin/src/main/java/com/ruoyi/pd/stockpd/service/impl/PdStockpdServiceImpl.java index 35c2200..6f0c4b6 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/pd/stockpd/service/impl/PdStockpdServiceImpl.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/pd/stockpd/service/impl/PdStockpdServiceImpl.java @@ -161,7 +161,7 @@ public class PdStockpdServiceImpl implements IPdStockpdService map.put("v_status", "5"); map.put("v_result", ""); pdStockpdMapper.updatePdStockpdByProc(map); - String result = (String) map.get("v_result"); + String result = Convert.toStr(map.get("v_result")); if ("10".equals(result)) { return AjaxResult.success("作废成功"); } @@ -182,7 +182,7 @@ public class PdStockpdServiceImpl implements IPdStockpdService map.put("v_status", "2"); map.put("v_result", ""); pdStockpdMapper.updatePdStockpdByProc(map); - String result = (String) map.get("v_result"); + String result = Convert.toStr(map.get("v_result")); if ("10".equals(result)) { return AjaxResult.success("审核成功"); } @@ -203,7 +203,7 @@ public class PdStockpdServiceImpl implements IPdStockpdService map.put("v_status", "4"); map.put("v_result", ""); pdStockpdMapper.updatePdStockpdByProc(map); - String result = (String) map.get("v_result"); + String result = Convert.toStr(map.get("v_result")); if ("10".equals(result)) { return AjaxResult.success("完成成功"); } @@ -431,6 +431,7 @@ public class PdStockpdServiceImpl implements IPdStockpdService jobDetail.setStockQuantity(stockDetail.getQuantity()); jobDetail.setOutQuantity(quantity); jobDetail.setStockinDetailId(stockDetail.getDetailId()); + jobDetail.setProjectNo(stockDetail.getProjectNo()); return jobDetail; } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/pda/domain/AddInJobRequest.java b/ruoyi-admin/src/main/java/com/ruoyi/pda/domain/AddInJobRequest.java index 918f6a0..72ccc16 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/pda/domain/AddInJobRequest.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/pda/domain/AddInJobRequest.java @@ -9,7 +9,7 @@ public class AddInJobRequest { @NotNull private String warehouseId; - @NotNull + /** PDA绑盘流程不再选择入口;保留该字段兼容旧的空托入库页面。 */ private String stationPointId; public String getTrayCode() { diff --git a/ruoyi-admin/src/main/java/com/ruoyi/pda/domain/BindTrayRequest.java b/ruoyi-admin/src/main/java/com/ruoyi/pda/domain/BindTrayRequest.java index b355cc5..23e3694 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/pda/domain/BindTrayRequest.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/pda/domain/BindTrayRequest.java @@ -21,8 +21,7 @@ public class BindTrayRequest { @NotNull private String warehouseId; - /** 入库入口点位编码 */ - @NotNull + /** 入库入口点位编码(已由WCS领取作业时赋值,PDA绑盘可不传) */ private String stationPointId; diff --git a/ruoyi-admin/src/main/java/com/ruoyi/pda/mapper/PdaMapper.java b/ruoyi-admin/src/main/java/com/ruoyi/pda/mapper/PdaMapper.java index a0cc9b3..c637040 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/pda/mapper/PdaMapper.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/pda/mapper/PdaMapper.java @@ -10,6 +10,16 @@ public interface PdaMapper { void addInJob(HashMap map); + /** + * 回滚同托盘同仓库残留待绑记录占用的入库子单扫码数量。 + */ + int rollbackPendingTrayBindings(HashMap map); + + /** + * 删除同托盘同仓库残留待绑记录,避免生成作业时混入旧明细。 + */ + int deletePendingTrayBindings(HashMap map); + /** * 查询托盘规则,校验托盘类型是否允许进入当前仓库,并带出允许货位类型。 * @@ -26,6 +36,22 @@ public interface PdaMapper { */ HashMap selectAvailableInboundRoadway(HashMap map); + /** + * 按仓库和托盘获取事务级入库货位分配锁。 + * + * @param map 仓库、托盘号 + * @return SQL Server 应用锁结果码,非负数表示成功 + */ + Integer acquireInboundCargoSpaceAllocationLock(HashMap map); + + Integer acquireInboundRoadwayAllocationLock(HashMap map); + + Integer acquireInboundLayerPlanningLock(HashMap map); + + HashMap selectAvailableInboundLayerCandidate(HashMap map); + + int assignInboundLayerIfUnassigned(HashMap map); + /** * WCS 请求货位时,优先选择已分配巷道内空货位最多的排,再锁定一个可入库空货位。 * diff --git a/ruoyi-admin/src/main/java/com/ruoyi/pda/service/PdaService.java b/ruoyi-admin/src/main/java/com/ruoyi/pda/service/PdaService.java index 2a7878d..9e459f3 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/pda/service/PdaService.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/pda/service/PdaService.java @@ -8,6 +8,7 @@ import com.ruoyi.pda.domain.AddInJobRequest; import com.ruoyi.pda.domain.BindTrayRequest; import com.ruoyi.pda.domain.EmptyTrayOutboundRequest; +import java.math.BigDecimal; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -111,17 +112,36 @@ public interface PdaService { * @param trayCode 托盘号 * @param warehouseId 仓库编码 * @param roadwayId 作业生成阶段分配的目标巷道,可为空 + * @param height WCS 上报的货物高度层级 * @return 已锁定货位信息 */ - Map lockInboundCargoSpace(String trayCode, String warehouseId, String roadwayId); + default Map lockInboundCargoSpace(String trayCode, String warehouseId, + String roadwayId, BigDecimal height) { + return lockInboundCargoSpace(trayCode, warehouseId, roadwayId, null, height); + } + + Map lockInboundCargoSpace(String trayCode, String warehouseId, String roadwayId, + String entranceId, BigDecimal height); + + /** + * 03仓第一阶段选择可行层,只回写作业roadway_id,不锁定具体货位。 + */ + default Map planInboundLayer(String trayCode, String warehouseId, String jobId, + List orderedLayers, BigDecimal height) { + return planInboundLayer(trayCode, warehouseId, jobId, null, orderedLayers, height); + } + + Map planInboundLayer(String trayCode, String warehouseId, String jobId, + String entranceId, List orderedLayers, BigDecimal height); /** * 释放指定托盘已锁定但未成功回写作业的入库货位。 * * @param trayCode 托盘号 * @param warehouseId 仓库编码 + * @param cargoSpaceId 本次请求锁定的候选货位ID */ - void releaseInboundCargoSpace(String trayCode, String warehouseId); + int releaseInboundCargoSpace(String trayCode, String warehouseId, String cargoSpaceId); /** * PDA 出库待确认任务列表。 diff --git a/ruoyi-admin/src/main/java/com/ruoyi/pda/service/impl/PdaServiceImpl.java b/ruoyi-admin/src/main/java/com/ruoyi/pda/service/impl/PdaServiceImpl.java index 2e56475..824b4af 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/pda/service/impl/PdaServiceImpl.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/pda/service/impl/PdaServiceImpl.java @@ -13,6 +13,7 @@ import com.ruoyi.common.core.text.Convert; import com.ruoyi.common.utils.DateUtils; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.in.job.domain.MydoJob; +import com.ruoyi.in.job.domain.InboundStorageProfile; import com.ruoyi.in.job.service.IMydoJobService; import com.ruoyi.in.jobdetail.domain.MydoJobDetail; import com.ruoyi.in.jobdetail.service.IMydoJobDetailService; @@ -32,6 +33,7 @@ import com.ruoyi.pda.domain.EmptyTrayOutboundRequest; import com.ruoyi.pda.mapper.PdaMapper; import com.ruoyi.pda.service.PdaService; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -50,6 +52,7 @@ import java.util.Map; */ @Service public class PdaServiceImpl implements PdaService { + private static final String DENSE_WAREHOUSE_ID = "03"; private static final String EMPTY_TRAY_PRODUCT_ID = "KTP0001"; private static final String EMPTY_TRAY_PRODUCT_NAME = "空容器"; private static final long EMPTY_TRAY_IN_JOB_TYPE = 3L; @@ -175,6 +178,14 @@ public class PdaServiceImpl implements PdaService { @Override @Transactional(rollbackFor = Exception.class) public String bindTrayAndAddInJob(BindTrayRequest request) { + checkBindTrayRequest(request); + if (!DENSE_WAREHOUSE_ID.equals(request.getWarehouseId()) + && !StringUtils.hasText(request.getStationPointId())) { + throw new RuntimeException("请选择入库点位"); + } + validateTrayRule(request.getTrayCode(), request.getWarehouseId()); + clearPendingTrayBindings(request.getTrayCode(), request.getWarehouseId()); + String bindResult = bindTray(request); if (StringUtils.hasText(bindResult)) { throw new RuntimeException(bindResult); @@ -183,10 +194,19 @@ public class PdaServiceImpl implements PdaService { AddInJobRequest addInJobRequest = new AddInJobRequest(); addInJobRequest.setTrayCode(request.getTrayCode()); addInJobRequest.setWarehouseId(request.getWarehouseId()); - addInJobRequest.setStationPointId(request.getStationPointId()); + addInJobRequest.setStationPointId(DENSE_WAREHOUSE_ID.equals(request.getWarehouseId()) + ? null : request.getStationPointId()); return addInJob(addInJobRequest); } + private void clearPendingTrayBindings(String trayCode, String warehouseId) { + HashMap map = new HashMap<>(); + map.put("v_tray_code", trayCode); + map.put("v_warehouse_id", warehouseId); + pdaMapper.rollbackPendingTrayBindings(map); + pdaMapper.deletePendingTrayBindings(map); + } + @Override public InStockinDetail getProductInfoByInDetailId(String detailId) { return inStockinDetailMapper.selectInStockinDetailByStockinDetailId(detailId); @@ -196,18 +216,30 @@ public class PdaServiceImpl implements PdaService { @Transactional(rollbackFor = Exception.class) public String addInJob(AddInJobRequest request) { checkAddInJobRequest(request); - BasStationPoint stationPoint = validateInboundStationPoint(request.getStationPointId(), request.getWarehouseId(), request.getTrayCode()); - ensureRoadwayHasEmptyCargoSpace(request.getTrayCode(), request.getWarehouseId(), stationPoint.getRoadwayId()); - int occupyRows = basStationPointService.occupyBasStationPoint(stationPoint.getPointId()); - if (occupyRows <= 0) { - throw new RuntimeException("入库点位已被分配,请重新选择点位"); + BasStationPoint stationPoint = null; + String selectedRoadway = ""; + // 兼容旧的手工生成入口;PDA绑盘不再传入该字段,入口由WCS领取时写回。 + if (StringUtils.hasText(request.getStationPointId())) { + stationPoint = validateInboundStationPoint(request.getStationPointId(), request.getWarehouseId(), request.getTrayCode()); + ensureRoadwayHasEmptyCargoSpace(request.getTrayCode(), request.getWarehouseId(), stationPoint.getRoadwayId()); + int occupyRows = basStationPointService.occupyBasStationPoint(stationPoint.getPointId()); + if (occupyRows <= 0) { + throw new RuntimeException("入库点位已被分配,请重新选择点位"); + } + } else if (!DENSE_WAREHOUSE_ID.equals(request.getWarehouseId())) { + selectedRoadway = Convert.toStr(selectAvailableInboundRoadway( + request.getTrayCode(), request.getWarehouseId()).get("roadway_id")); } HashMap map = new HashMap<>(); map.put("v_tray_code", request.getTrayCode()); map.put("v_warehouse_id", request.getWarehouseId()); map.put("v_dept_id", SecurityUtils.getDeptId()); map.put("v_user_id", SecurityUtils.getUserId()); - pdaMapper.addInJob(map); + try { + pdaMapper.addInJob(map); + } catch (DuplicateKeyException e) { + throw new RuntimeException("托盘已存在未完成作业", e); + } if (!isProcedureSuccess(map.get("v_result"))) { throw new RuntimeException(Convert.toStr(map.get("v_resultmsg"))); @@ -224,13 +256,14 @@ public class PdaServiceImpl implements PdaService { } // 普通入库不使用 target_* 字段;生成作业阶段只写入口点位和作业巷道,不锁定具体货位。 - job.setEntranceId(stationPoint.getPointId()); - job.setRoadwayId(stationPoint.getRoadwayId()); + job.setEntranceId(stationPoint == null ? "" : stationPoint.getPointId()); + job.setRoadwayId(DENSE_WAREHOUSE_ID.equals(request.getWarehouseId()) ? "" + : (stationPoint == null ? selectedRoadway : stationPoint.getRoadwayId())); BasWarehouse warehouse = basWarehouseService.selectBasWarehouseByWarehouseId(request.getWarehouseId()); if (warehouse != null) { job.setWarehouseName(warehouse.getWarehouseName()); } - job.setStatus("1"); + job.setStatus(DENSE_WAREHOUSE_ID.equals(request.getWarehouseId()) ? "2" : "1"); job.setExecutionPhase("0"); // updateMydoJob 只更新非 null 字段,这里使用空字符串覆盖存储过程可能写入的作业货位。 job.setCargoSpaceId(""); @@ -248,6 +281,10 @@ public class PdaServiceImpl implements PdaService { } markStockinRunning(detailList); + if (DENSE_WAREHOUSE_ID.equals(request.getWarehouseId())) { + return resultMsg + ";密集库入库作业已生成,等待WCS获取"; + } + ApiResponse wcsResponse = wcsClient.sendPutawayTask(job); if (wcsResponse == null || !wcsResponse.isSuccess()) { String message = wcsResponse == null ? "WCS接口无返回" : wcsResponse.getMessage(); @@ -274,7 +311,7 @@ public class PdaServiceImpl implements PdaService { job.setJobId(jobId); job.setTaskNo(StringUtils.hasText(taskNo) ? taskNo : jobId); job.setInOrOut(EMPTY_TRAY_IN_JOB_TYPE); - job.setStatus("1"); + job.setStatus(DENSE_WAREHOUSE_ID.equals(request.getWarehouseId()) ? "2" : "1"); job.setExecutionPhase("0"); job.setTrayCode(request.getTrayCode()); job.setWarehouseId(request.getWarehouseId()); @@ -283,7 +320,7 @@ public class PdaServiceImpl implements PdaService { job.setWarehouseName(warehouse.getWarehouseName()); } job.setEntranceId(stationPoint.getPointId()); - job.setRoadwayId(stationPoint.getRoadwayId()); + job.setRoadwayId(DENSE_WAREHOUSE_ID.equals(request.getWarehouseId()) ? "" : stationPoint.getRoadwayId()); job.setPriority(10L); job.setHeight(BigDecimal.ZERO); job.setWeight(BigDecimal.ZERO); @@ -302,6 +339,10 @@ public class PdaServiceImpl implements PdaService { throw new RuntimeException("空托入库作业明细生成失败"); } + if (DENSE_WAREHOUSE_ID.equals(request.getWarehouseId())) { + return "空托入库作业生成成功:" + job.getJobId() + ";密集库作业等待WCS获取"; + } + ApiResponse wcsResponse = wcsClient.sendPutawayTask(job); if (wcsResponse == null || !wcsResponse.isSuccess()) { String message = wcsResponse == null ? "WCS接口无返回" : wcsResponse.getMessage(); @@ -464,6 +505,7 @@ public class PdaServiceImpl implements PdaService { detail.setStockQuantity(toBigDecimal(mapValue(stock, "availableQuantity"))); detail.setOutQuantity(ONE_EMPTY_TRAY); detail.setStockinDetailId(mapString(stock, "stockDetailId")); + detail.setProjectNo(mapString(stock, "projectNo")); detail.setSupplierId(mapString(stock, "supplierId")); detail.setSupplierName(mapString(stock, "supplierName")); return detail; @@ -507,9 +549,6 @@ public class PdaServiceImpl implements PdaService { if (!StringUtils.hasText(request.getWarehouseId())) { throw new RuntimeException("仓库编码不能为空"); } - if (!StringUtils.hasText(request.getStationPointId())) { - throw new RuntimeException("入库入口点位不能为空"); - } } private boolean isProcedureSuccess(Object result) { @@ -623,7 +662,74 @@ public class PdaServiceImpl implements PdaService { * bas_tray_rule 维护托盘尺寸、允许仓库和允许货位类型;bas_cargo_space 按层、排、列升序选择空货位。 */ @Override - public Map lockInboundCargoSpace(String trayCode, String warehouseId, String roadwayId) { + @Transactional(rollbackFor = Exception.class) + public Map planInboundLayer(String trayCode, String warehouseId, String jobId, + String entranceId, List orderedLayers, BigDecimal height) { + if (!DENSE_WAREHOUSE_ID.equals(warehouseId)) { + throw new RuntimeException("仅03仓使用两阶段选层"); + } + if (!StringUtils.hasText(jobId) || !StringUtils.hasText(trayCode)) { + throw new RuntimeException("入库作业ID和托盘号不能为空"); + } + if (!isSupportedInboundHeight(height)) { + throw new RuntimeException("height必须为1或2"); + } + if (orderedLayers == null || orderedLayers.isEmpty()) { + throw new RuntimeException("可用层不能为空"); + } + + HashMap rule = validateTrayRule(trayCode, warehouseId); + HashMap params = new HashMap<>(); + params.put("v_tray_code", trayCode); + params.put("v_warehouse_id", warehouseId); + params.put("v_job_id", jobId); + params.put("v_entrance_id", entranceId); + params.put("v_cargo_type", Convert.toStr(rule.get("allowed_cargo_type"))); + params.put("v_height", height); + + Integer trayLockResult = pdaMapper.acquireInboundCargoSpaceAllocationLock(params); + if (trayLockResult == null || trayLockResult < 0) { + throw new RuntimeException("入库货位分配锁获取失败,结果码:" + trayLockResult); + } + Integer planningLockResult = pdaMapper.acquireInboundLayerPlanningLock(params); + if (planningLockResult == null || planningLockResult < 0) { + throw new RuntimeException("入库选层规划锁获取失败,结果码:" + planningLockResult); + } + + InboundStorageProfile profile = mydoJobService.resolveInboundStorageProfile(trayCode, warehouseId); + profile.putInto(params); + HashMap selected = null; + String selectedLayer = null; + for (Integer layer : orderedLayers) { + HashMap layerQuery = new HashMap<>(params); + layerQuery.put("v_roadway_id", String.valueOf(layer)); + selected = pdaMapper.selectAvailableInboundLayerCandidate(layerQuery); + if (selected != null && !selected.isEmpty()) { + selectedLayer = String.valueOf(layer); + break; + } + } + if (selected == null || selected.isEmpty() || selectedLayer == null) { + throw new RuntimeException("WCS可用层均无符合通道规则的货位"); + } + + params.put("v_roadway_id", selectedLayer); + if (pdaMapper.assignInboundLayerIfUnassigned(params) <= 0) { + throw new RuntimeException("入库作业选层回写失败"); + } + HashMap result = new HashMap<>(selected); + result.put("roadway_id", selectedLayer); + result.put("selected_layer", Integer.valueOf(selectedLayer)); + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map lockInboundCargoSpace(String trayCode, String warehouseId, String roadwayId, + String entranceId, BigDecimal height) { + if (!isSupportedInboundHeight(height)) { + throw new RuntimeException("height必须为1或2"); + } if (!StringUtils.hasText(roadwayId)) { throw new RuntimeException("入库作业未配置巷道,不能分配货位"); } @@ -633,12 +739,37 @@ public class PdaServiceImpl implements PdaService { lockMap.put("v_warehouse_id", warehouseId); lockMap.put("v_cargo_type", Convert.toStr(rule.get("allowed_cargo_type"))); lockMap.put("v_roadway_id", roadwayId); + lockMap.put("v_entrance_id", entranceId); + lockMap.put("v_height", height); + + Integer allocationLockResult = pdaMapper.acquireInboundCargoSpaceAllocationLock(lockMap); + if (allocationLockResult == null || allocationLockResult < 0) { + throw new RuntimeException("入库货位分配锁获取失败,结果码:" + allocationLockResult); + } HashMap locked = pdaMapper.selectLockedCargoSpaceByTrayCode(lockMap); if (locked != null && !locked.isEmpty()) { + if (!roadwayId.equals(lockedAllocationDimension(warehouseId, locked))) { + throw new RuntimeException("托盘已在其他巷道锁定货位"); + } + validateLockedCargoSpaceHeight(locked, height); + normalizeDenseSelectedLayer(warehouseId, roadwayId, locked); return locked; } + if (DENSE_WAREHOUSE_ID.equals(warehouseId)) { + InboundStorageProfile profile = mydoJobService.resolveInboundStorageProfile(trayCode, warehouseId); + profile.putInto(lockMap); + Integer planningLockResult = pdaMapper.acquireInboundLayerPlanningLock(lockMap); + if (planningLockResult == null || planningLockResult < 0) { + throw new RuntimeException("入库选层规划锁获取失败,结果码:" + planningLockResult); + } + Integer roadwayLockResult = pdaMapper.acquireInboundRoadwayAllocationLock(lockMap); + if (roadwayLockResult == null || roadwayLockResult < 0) { + throw new RuntimeException("入库巷道分配锁获取失败,结果码:" + roadwayLockResult); + } + } + int count = pdaMapper.lockInboundCargoSpace(lockMap); if (count <= 0) { throw new RuntimeException("当前巷道无符合托盘规则的空货位"); @@ -647,15 +778,55 @@ public class PdaServiceImpl implements PdaService { if (locked == null || locked.isEmpty()) { throw new RuntimeException("货位分配失败"); } + if (!roadwayId.equals(lockedAllocationDimension(warehouseId, locked))) { + throw new RuntimeException("托盘已在其他巷道锁定货位"); + } + validateLockedCargoSpaceHeight(locked, height); + normalizeDenseSelectedLayer(warehouseId, roadwayId, locked); return locked; } + private String lockedAllocationDimension(String warehouseId, Map locked) { + return DENSE_WAREHOUSE_ID.equals(warehouseId) + ? Convert.toStr(locked.get("the_floor")) + : Convert.toStr(locked.get("roadway_id")); + } + + private void normalizeDenseSelectedLayer(String warehouseId, String selectedLayer, + HashMap locked) { + if (DENSE_WAREHOUSE_ID.equals(warehouseId)) { + locked.put("roadway_id", selectedLayer); + } + } + + private boolean isSupportedInboundHeight(BigDecimal height) { + return height != null + && (height.compareTo(BigDecimal.ONE) == 0 + || height.compareTo(BigDecimal.valueOf(2)) == 0); + } + + private void validateLockedCargoSpaceHeight(Map locked, BigDecimal requestedHeight) { + BigDecimal lockedHeight = null; + Object rawHeight = locked.get("the_height"); + if (rawHeight != null && StringUtils.hasText(Convert.toStr(rawHeight))) { + try { + lockedHeight = new BigDecimal(Convert.toStr(rawHeight)); + } catch (NumberFormatException ignored) { + // Invalid master data is treated as incompatible with every request. + } + } + if (!isSupportedInboundHeight(lockedHeight) || lockedHeight.compareTo(requestedHeight) < 0) { + throw new RuntimeException("已锁定货位高度不满足请求"); + } + } + @Override - public void releaseInboundCargoSpace(String trayCode, String warehouseId) { + public int releaseInboundCargoSpace(String trayCode, String warehouseId, String cargoSpaceId) { HashMap map = new HashMap<>(); map.put("v_tray_code", trayCode); map.put("v_warehouse_id", warehouseId); - pdaMapper.releaseInboundCargoSpace(map); + map.put("v_cargo_space_id", cargoSpaceId); + return pdaMapper.releaseInboundCargoSpace(map); } @Override @@ -903,7 +1074,8 @@ public class PdaServiceImpl implements PdaService { returnJob.setHeight(BigDecimal.ZERO); returnJob.setWeight(BigDecimal.ZERO); copyLoginUser(returnJob); - if (StringUtils.hasText(outboundJob.getExitId())) { + if (!DENSE_WAREHOUSE_ID.equals(outboundJob.getWarehouseId()) + && StringUtils.hasText(outboundJob.getExitId())) { BasStationPoint exitPoint = basStationPointService.selectBasStationPointByPointId(outboundJob.getExitId()); if (exitPoint != null) { returnJob.setRoadwayId(exitPoint.getRoadwayId()); @@ -931,7 +1103,9 @@ public class PdaServiceImpl implements PdaService { returnDetail.setProductName(mapProductName(material)); returnDetail.setInQuantity(quantity); returnDetail.setStockQuantity(quantity); + returnDetail.setStockinId(mapString(material, "stockinId")); returnDetail.setStockinDetailId(mapString(material, "stockDetailId")); + returnDetail.setProjectNo(mapString(material, "projectNo")); mydoJobDetailService.insertMydoJobDetail(returnDetail); } @@ -1054,7 +1228,8 @@ public class PdaServiceImpl implements PdaService { returnJob.setHeight(BigDecimal.ZERO); returnJob.setWeight(BigDecimal.ZERO); copyLoginUser(returnJob); - if (StringUtils.hasText(outboundJob.getExitId())) { + if (!DENSE_WAREHOUSE_ID.equals(outboundJob.getWarehouseId()) + && StringUtils.hasText(outboundJob.getExitId())) { BasStationPoint exitPoint = basStationPointService.selectBasStationPointByPointId(outboundJob.getExitId()); if (exitPoint != null) { returnJob.setRoadwayId(exitPoint.getRoadwayId()); @@ -1081,6 +1256,7 @@ public class PdaServiceImpl implements PdaService { returnDetail.setInQuantity(quantity); returnDetail.setStockQuantity(quantity); returnDetail.setStockinDetailId(Convert.toStr(material.get("stockDetailId"))); + returnDetail.setProjectNo(Convert.toStr(material.get("projectNo"))); returnDetail.setSupplierId(Convert.toStr(material.get("supplierId"))); returnDetail.setSupplierName(Convert.toStr(material.get("supplierName"))); mydoJobDetailService.insertMydoJobDetail(returnDetail); diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index e4d4516..7af0762 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -166,10 +166,15 @@ external: reportMaterialConsume: /material/consume reportQualityCheck: /quality/check WCS: - base-url: http://172.16.4.63:8084 + base-url: ${WCS_DEFAULT_BASE_URL:http://172.16.4.63:8084} token: wcs-token # 本地测试默认启用模拟返回;联调或生产设为 false 即可恢复真实 WCS 调用 - mock-enabled: false + mock-enabled: true + # 各仓库WCS地址独立配置;未列出的仓库兼容使用上面的默认地址。 + warehouse-base-urls: + '01': ${WCS_01_BASE_URL:${WCS_DEFAULT_BASE_URL:http://172.16.4.63:8084}} + '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 diff --git a/ruoyi-admin/src/main/resources/mapper/bas/BasCargoSpaceMapper.xml b/ruoyi-admin/src/main/resources/mapper/bas/BasCargoSpaceMapper.xml index bbb6476..e396035 100644 --- a/ruoyi-admin/src/main/resources/mapper/bas/BasCargoSpaceMapper.xml +++ b/ruoyi-admin/src/main/resources/mapper/bas/BasCargoSpaceMapper.xml @@ -35,7 +35,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - select top 1 cargo_space_id, warehouse_id, roadway_id, cargo_area_id, the_type, the_type_name, cargo_space_name, the_platoon, the_column, the_floor, status, locked, tray_code, iss_double, group_coding_no, group_coding, cargo_space_logo, this_cargo_area, dept_id, dept_name, the_weight, the_height - from ( - select bcs.cargo_space_id, bcs.warehouse_id, bcs.roadway_id, bcs.cargo_area_id, bcs.the_type, bcs.the_type_name, - bcs.cargo_space_name, bcs.the_platoon, bcs.the_column, bcs.the_floor, bcs.status, bcs.locked, - bcs.tray_code, bcs.iss_double, bcs.group_coding_no, bcs.group_coding, bcs.cargo_space_logo, - bcs.this_cargo_area, bcs.dept_id, bcs.dept_name, bcs.the_weight, bcs.the_height, - count(1) over (partition by bcs.the_platoon) as platoon_empty_count - from bas_cargo_space bcs - where bcs.warehouse_id = #{warehouseId} - and bcs.roadway_id = #{roadwayId} - and bcs.status = '1' - and ISNULL(bcs.locked, 'N') = 'N' - and (#{cargoSpaceId} is null or bcs.cargo_space_id != #{cargoSpaceId}) - and bcs.the_type = (select top 1 btr.allowed_cargo_type - from bas_tray_rule btr - where btr.tray_type is not null - and btr.tray_type != '' - and #{trayCode} is not null - and #{trayCode} != '' - and left(#{trayCode}, len(btr.tray_type)) = btr.tray_type - and charindex(',' + #{warehouseId} + ',', ',' + btr.allowed_warehouses + ',') > 0 - order by len(btr.tray_type) desc, btr.id) - ) candidate - order by platoon_empty_count desc, the_platoon asc, the_floor asc, the_column asc, cargo_space_id asc + + + ;with lift_conflict_floors as ( + select distinct outbound_space.the_floor as conflict_floor + from mydo_job inbound_job + inner join mydo_job outbound_job + on outbound_job.warehouse_id = inbound_job.warehouse_id + and outbound_job.exit_id = inbound_job.entrance_id + inner join bas_cargo_space outbound_space + on outbound_space.cargo_space_id = outbound_job.cargo_space_id + where inbound_job.job_id = #{jobId} + and outbound_job.in_or_out >= 20 + and outbound_job.status = '3' + and outbound_space.the_floor is not null + ), + available_slots as ( + select bcs.cargo_space_id, bcs.warehouse_id, bcs.roadway_id, bcs.cargo_area_id, + bcs.the_type, bcs.the_type_name, bcs.cargo_space_name, bcs.the_platoon, + bcs.the_column, bcs.the_floor, bcs.status, bcs.locked, bcs.tray_code, + bcs.iss_double, bcs.group_coding_no, bcs.group_coding, bcs.cargo_space_logo, + bcs.this_cargo_area, bcs.dept_id, bcs.dept_name, bcs.the_weight, bcs.the_height, + coalesce(cac.direction_type, '1') as direction_type, + case when bcs.the_height = #{height} then 0 else 1 end as height_priority, + case + when upper(ltrim(rtrim(isnull(#{entranceId}, '')))) in ('03-AU-01', '03-AU-03') + then case when bcs.this_cargo_area in ('G', 'H') then 0 else 1 end + when upper(ltrim(rtrim(isnull(#{entranceId}, '')))) in ('03-AU-02', '03-AU-04') + then case when bcs.this_cargo_area in ('A', 'B') then 0 else 1 end + else 0 + end as entrance_area_priority + from bas_cargo_space bcs + left join bas_cargo_area_config cac + on cac.warehouse_id = bcs.warehouse_id + and cac.cargo_area_code = bcs.this_cargo_area + and cac.is_active = '1' + where bcs.warehouse_id = #{warehouseId} + and bcs.the_floor = try_convert(bigint, #{roadwayId}) + and bcs.the_height >= #{height} + and bcs.status = '1' + and isnull(bcs.locked, 'N') = 'N' + and (bcs.tray_code is null or ltrim(rtrim(bcs.tray_code)) = '') + and (#{cargoSpaceId} is null or bcs.cargo_space_id != #{cargoSpaceId}) + and bcs.group_coding is not null + and ltrim(rtrim(bcs.group_coding)) != '' + and ((bcs.this_cargo_area in ('A', 'B') and bcs.the_column between 1 and 6) + or (bcs.this_cargo_area in ('C', 'D') and bcs.the_column between 8 and 12) + or (bcs.this_cargo_area in ('E', 'F') and bcs.the_column between 14 and 18) + or (bcs.this_cargo_area in ('G', 'H') and bcs.the_column between 20 and 22)) + and not exists ( + select 1 + from lift_conflict_floors + where lift_conflict_floors.conflict_floor = bcs.the_floor + ) + and bcs.the_type = (select top 1 btr.allowed_cargo_type + from bas_tray_rule btr + where btr.tray_type is not null + and btr.tray_type != '' + and #{trayCode} is not null + and #{trayCode} != '' + and left(#{trayCode}, len(btr.tray_type)) = btr.tray_type + and charindex(',' + #{warehouseId} + ',', ',' + btr.allowed_warehouses + ',') > 0 + order by len(btr.tray_type) desc, btr.id) + ), + stock_profiles as ( + select occupied.cargo_space_id, occupied.group_coding, + case + when ltrim(rtrim(isnull(bsd.product_id, ''))) = 'KTP0001' then 'EMPTY_TRAY' + when ltrim(rtrim(isnull(coalesce(bsd.project_no, bs.project_no), ''))) != '' and bs.in_type = '1' then 'RAW_PROJECT' + when ltrim(rtrim(isnull(coalesce(bsd.project_no, bs.project_no), ''))) != '' and bs.in_type = '2' then 'SEMI_PROJECT' + when ltrim(rtrim(isnull(coalesce(bsd.project_no, bs.project_no), ''))) = '' and bs.in_type = '1' then 'RAW_PRODUCT' + else 'UNKNOWN' + end as storage_mode, + nullif(ltrim(rtrim(coalesce(bsd.project_no, bs.project_no))), '') as project_no, + nullif(ltrim(rtrim(bsd.product_id)), '') as product_id, + nullif(ltrim(rtrim(bsd.product_name)), '') as product_name + from bas_stock bs + inner join bas_stock_detail bsd on bsd.stock_id = bs.stock_id + inner join bas_cargo_space occupied on occupied.cargo_space_id = bs.cargo_space_id + where bs.warehouse_id = #{warehouseId} + and occupied.the_floor = try_convert(bigint, #{roadwayId}) + and occupied.group_coding is not null + and isnull(bsd.quantity, 0) - isnull(bsd.out_quantity, 0) > 0 + ), + assigned_job_profiles as ( + select assigned.cargo_space_id, assigned.group_coding, + case + when ltrim(rtrim(isnull(jd.product_id, ''))) = 'KTP0001' then 'EMPTY_TRAY' + when ltrim(rtrim(isnull(jd.project_no, ''))) != '' and ins.in_type = '1' then 'RAW_PROJECT' + when ltrim(rtrim(isnull(jd.project_no, ''))) != '' and ins.in_type = '2' then 'SEMI_PROJECT' + when ltrim(rtrim(isnull(jd.project_no, ''))) = '' and ins.in_type = '1' then 'RAW_PRODUCT' + else 'UNKNOWN' + end as storage_mode, + nullif(ltrim(rtrim(jd.project_no)), '') as project_no, + nullif(ltrim(rtrim(jd.product_id)), '') as product_id, + nullif(ltrim(rtrim(jd.product_name)), '') as product_name + from mydo_job j + inner join mydo_job_detail jd on jd.job_id = j.job_id + left join in_stockin ins on ins.stockin_id = jd.stockin_id + inner join bas_cargo_space assigned on assigned.cargo_space_id = j.cargo_space_id + where j.warehouse_id = #{warehouseId} + and assigned.the_floor = try_convert(bigint, #{roadwayId}) + and j.in_or_out < 20 + and j.status in ('1', '2', '3') + and j.job_id != #{jobId} + and assigned.group_coding is not null + ), + reserved_job_profiles as ( + select reserved.cargo_space_id, reserved.group_coding, + case + when ltrim(rtrim(isnull(jd.product_id, ''))) = 'KTP0001' then 'EMPTY_TRAY' + when ltrim(rtrim(isnull(jd.project_no, ''))) != '' and ins.in_type = '1' then 'RAW_PROJECT' + when ltrim(rtrim(isnull(jd.project_no, ''))) != '' and ins.in_type = '2' then 'SEMI_PROJECT' + when ltrim(rtrim(isnull(jd.project_no, ''))) = '' and ins.in_type = '1' then 'RAW_PRODUCT' + else 'UNKNOWN' + end as storage_mode, + nullif(ltrim(rtrim(jd.project_no)), '') as project_no, + nullif(ltrim(rtrim(jd.product_id)), '') as product_id, + nullif(ltrim(rtrim(jd.product_name)), '') as product_name + from bas_cargo_space reserved + inner join mydo_job j + on j.warehouse_id = reserved.warehouse_id + and j.tray_code = reserved.tray_code + inner join mydo_job_detail jd on jd.job_id = j.job_id + left join in_stockin ins on ins.stockin_id = jd.stockin_id + where reserved.warehouse_id = #{warehouseId} + and reserved.the_floor = try_convert(bigint, #{roadwayId}) + and reserved.status = '3' + and reserved.tray_code is not null + and ltrim(rtrim(reserved.tray_code)) != '' + and reserved.group_coding is not null + and j.in_or_out < 20 + and j.status in ('1', '2', '3') + and j.job_id != #{jobId} + ), + profiled_occupancy as ( + select cargo_space_id, group_coding, storage_mode, project_no, product_id, product_name from stock_profiles + union all + select cargo_space_id, group_coding, storage_mode, project_no, product_id, product_name from assigned_job_profiles + union all + select cargo_space_id, group_coding, storage_mode, project_no, product_id, product_name from reserved_job_profiles + ), + occupied_spaces as ( + select bcs.cargo_space_id, bcs.group_coding + from bas_cargo_space bcs + where bcs.warehouse_id = #{warehouseId} + and bcs.the_floor = try_convert(bigint, #{roadwayId}) + and bcs.group_coding is not null + and bcs.status not in ('1', '7', '8', '9', '10') + ), + unknown_occupancy as ( + select occupied.cargo_space_id, occupied.group_coding, 'UNKNOWN' as storage_mode, + cast(null as nvarchar(80)) as project_no, + cast(null as nvarchar(80)) as product_id, + cast(null as nvarchar(200)) as product_name + from occupied_spaces occupied + where not exists ( + select 1 from profiled_occupancy profiled + where profiled.cargo_space_id = occupied.cargo_space_id + ) + ), + all_occupancy as ( + select cargo_space_id, group_coding, storage_mode, project_no, product_id, product_name from profiled_occupancy + union all + select cargo_space_id, group_coding, storage_mode, project_no, product_id, product_name from unknown_occupancy + ), + eligible_lanes as ( + select lane.group_coding, + case when count(distinct occupancy.cargo_space_id) > 0 then 1 else 0 end as has_occupancy, + count(distinct occupancy.cargo_space_id) as occupied_space_count + from (select distinct group_coding from available_slots) lane + left join all_occupancy occupancy on occupancy.group_coding = lane.group_coding + group by lane.group_coding + having sum(case + when occupancy.cargo_space_id is null then 0 + when #{storageMode} = 'EMPTY_TRAY' and occupancy.storage_mode = 'EMPTY_TRAY' + and occupancy.product_id = 'KTP0001' then 0 + when #{storageMode} = 'RAW_PRODUCT' and occupancy.storage_mode = 'RAW_PRODUCT' + and occupancy.project_no is null + and occupancy.product_id = #{productId} + and occupancy.product_name = #{productName} then 0 + when #{storageMode} in ('RAW_PROJECT', 'SEMI_PROJECT') + and occupancy.storage_mode in ('RAW_PROJECT', 'SEMI_PROJECT') + and occupancy.project_no = #{projectNo} then 0 + else 1 + end) = 0 + ), + lane_candidates as ( + select slots.*, lanes.has_occupancy, lanes.occupied_space_count, + abs(convert(bigint, checksum(slots.group_coding, #{trayCode}))) as lane_random_key + from available_slots slots + inner join eligible_lanes lanes on lanes.group_coding = slots.group_coding + ) + select top 1 cargo_space_id, warehouse_id, roadway_id, cargo_area_id, the_type, + the_type_name, cargo_space_name, the_platoon, the_column, the_floor, status, + locked, tray_code, iss_double, group_coding_no, group_coding, cargo_space_logo, + this_cargo_area, dept_id, dept_name, the_weight, the_height + from lane_candidates + order by height_priority asc, + entrance_area_priority asc, + case when #{storageMode} = 'EMPTY_TRAY' then lane_random_key end asc, + case when #{storageMode} != 'EMPTY_TRAY' then has_occupancy end desc, + case when #{storageMode} != 'EMPTY_TRAY' then occupied_space_count end desc, + group_coding asc, + the_platoon asc, + the_floor asc, + case when direction_type = '1' then the_column end asc, + case when direction_type = '2' then the_column end desc, + the_column asc, + cargo_space_id asc + + + select top 1 cargo_space_id, warehouse_id, roadway_id, cargo_area_id, the_type, the_type_name, cargo_space_name, the_platoon, the_column, the_floor, status, locked, tray_code, iss_double, group_coding_no, group_coding, cargo_space_logo, this_cargo_area, dept_id, dept_name, the_weight, the_height + from ( + select bcs.cargo_space_id, bcs.warehouse_id, bcs.roadway_id, bcs.cargo_area_id, bcs.the_type, bcs.the_type_name, + bcs.cargo_space_name, bcs.the_platoon, bcs.the_column, bcs.the_floor, bcs.status, bcs.locked, + bcs.tray_code, bcs.iss_double, bcs.group_coding_no, bcs.group_coding, bcs.cargo_space_logo, + bcs.this_cargo_area, bcs.dept_id, bcs.dept_name, bcs.the_weight, bcs.the_height, + coalesce(cac.direction_type, '1') as direction_type, + min(bcs.the_column) over (partition by bcs.warehouse_id, bcs.this_cargo_area) as area_min_column, + max(bcs.the_column) over (partition by bcs.warehouse_id, bcs.this_cargo_area) as area_max_column, + case when bcs.the_height = #{height} then 0 else 1 end as height_priority, + count(1) over (partition by bcs.the_platoon) as platoon_empty_count + from bas_cargo_space bcs + left join bas_cargo_area_config cac + on cac.warehouse_id = bcs.warehouse_id + and cac.cargo_area_code = bcs.this_cargo_area + and cac.is_active = '1' + where bcs.warehouse_id = #{warehouseId} + and bcs.roadway_id = #{roadwayId} + and bcs.the_height >= #{height} + and bcs.status = '1' + and ISNULL(bcs.locked, 'N') = 'N' + and (bcs.tray_code is null or ltrim(rtrim(bcs.tray_code)) = '') + and (#{cargoSpaceId} is null or bcs.cargo_space_id != #{cargoSpaceId}) + and bcs.the_type = (select top 1 btr.allowed_cargo_type + from bas_tray_rule btr + where btr.tray_type is not null + and btr.tray_type != '' + and #{trayCode} is not null + and #{trayCode} != '' + and left(#{trayCode}, len(btr.tray_type)) = btr.tray_type + and charindex(',' + #{warehouseId} + ',', ',' + btr.allowed_warehouses + ',') > 0 + order by len(btr.tray_type) desc, btr.id) + ) candidate + order by height_priority asc, platoon_empty_count desc, + the_platoon asc, the_floor asc, + case when direction_type = '1' then the_column end asc, + case when direction_type = '2' then the_column end desc, + case when direction_type = '3' then + case + when the_column - area_min_column <= area_max_column - the_column + then the_column - area_min_column + else area_max_column - the_column + end + end asc, + case when direction_type = '3' then the_column end asc, + cargo_space_id asc + + + + update bas_cargo_space + set status = '3', + tray_code = #{trayCode} + where cargo_space_id = #{cargoSpaceId} + and warehouse_id = #{warehouseId} + + + and the_floor = #{theFloor} + + + and roadway_id = #{roadwayId} + + + and the_height >= #{theHeight} + and status = '1' + and ISNULL(locked, 'N') = 'N' + and (tray_code is null or ltrim(rtrim(tray_code)) = '') + + delete from bas_cargo_space where cargo_space_id = #{cargoSpaceId} diff --git a/ruoyi-admin/src/main/resources/mapper/bas/BasStockDetailMapper.xml b/ruoyi-admin/src/main/resources/mapper/bas/BasStockDetailMapper.xml index 1f14414..60b95cc 100644 --- a/ruoyi-admin/src/main/resources/mapper/bas/BasStockDetailMapper.xml +++ b/ruoyi-admin/src/main/resources/mapper/bas/BasStockDetailMapper.xml @@ -15,6 +15,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + @@ -32,7 +33,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" select bsd.detail_id, bsd.stock_id, bsd.product_status, bsd.lot_no, bsd.product_id, bsd.product_name, - bsd.quantity, bsd.out_quantity, bsd.job_detail_id, bsd.stockin_detail_id, + bsd.quantity, bsd.out_quantity, bsd.job_detail_id, bsd.stockin_detail_id, bsd.project_no, bs.cargo_space_id, bcs.cargo_space_name, bs.stock_status, bs.in_date, bs.back_date, bs.tray_code, bs.warehouse_id, ISNULL(NULLIF(bs.warehouse_name, ''), bw.warehouse_name) as warehouse_name, bbf.tray_type, @@ -65,6 +66,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and bsd.product_name like concat('%', #{productName}, '%') and bsd.job_detail_id = #{jobDetailId} and bsd.stockin_detail_id = #{stockinDetailId} + and bsd.project_no = #{projectNo} and bs.cargo_space_id = #{cargoSpaceId} and bs.stock_status = #{stockStatus} and bs.tray_code = #{trayCode} @@ -108,6 +110,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" out_quantity, job_detail_id, stockin_detail_id, + project_no, create_time, update_time, remark, @@ -123,6 +126,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{outQuantity}, #{jobDetailId}, #{stockinDetailId}, + #{projectNo}, #{createTime}, #{updateTime}, #{remark}, @@ -139,8 +143,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" product_name = #{productName}, quantity = #{quantity}, out_quantity = #{outQuantity}, - job_detail_id = #{jobDetailId}, - stockin_detail_id = #{stockinDetailId}, + project_no = #{projectNo}, update_time = #{updateTime}, remark = #{remark}, diff --git a/ruoyi-admin/src/main/resources/mapper/bas/BasStockMapper.xml b/ruoyi-admin/src/main/resources/mapper/bas/BasStockMapper.xml index 149e00e..1e3ab65 100644 --- a/ruoyi-admin/src/main/resources/mapper/bas/BasStockMapper.xml +++ b/ruoyi-admin/src/main/resources/mapper/bas/BasStockMapper.xml @@ -21,6 +21,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + @@ -35,7 +36,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - select stock_id, cargo_space_id, stock_status, product_status, lot_no, in_date, back_date, product_id, product_name, quantity, job_id, job_detail_id, tray_code, out_quantity, stockin_detail_id, stockin_id, in_type, warehouse_id, + select stock_id, cargo_space_id, stock_status, product_status, lot_no, in_date, back_date, product_id, product_name, quantity, job_id, job_detail_id, tray_code, out_quantity, stockin_detail_id, stockin_id, project_no, in_type, warehouse_id, ISNULL(NULLIF(warehouse_name, ''), (select top 1 bw.warehouse_name from bas_warehouse bw where bw.warehouse_id = bas_stock.warehouse_id)) as warehouse_name, user_id, user_name, dept_id, dept_name, supplier_id as suplier_id, supplier_name as suplier_name from bas_stock @@ -48,7 +49,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" bs.job_id, null as job_detail_id, bs.tray_code, ISNULL(bsa.out_quantity, 0) as out_quantity, null as stockin_detail_id, - bs.stockin_id, bs.in_type, bs.warehouse_id, ISNULL(NULLIF(bs.warehouse_name, ''), bw.warehouse_name) as warehouse_name, + bs.stockin_id, bs.project_no, bs.in_type, bs.warehouse_id, ISNULL(NULLIF(bs.warehouse_name, ''), bw.warehouse_name) as warehouse_name, bs.user_id, bs.user_name, bs.dept_id, bs.dept_name, bs.supplier_id as suplier_id, bs.supplier_name as suplier_name, ISNULL(bsa.detail_count, 0) as detail_count, ISNULL(bsa.quantity, 0) - ISNULL(bsa.out_quantity, 0) as available_quantity @@ -81,6 +82,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and (ISNULL(bsa.out_quantity, 0) = #{outQuantity} or exists (select 1 from bas_stock_detail x where x.stock_id = bs.stock_id and x.out_quantity = #{outQuantity})) and exists (select 1 from bas_stock_detail x where x.stock_id = bs.stock_id and x.stockin_detail_id = #{stockinDetailId}) and bs.stockin_id = #{stockinId} + and (bs.project_no = #{projectNo} or exists (select 1 from bas_stock_detail x where x.stock_id = bs.stock_id and x.project_no = #{projectNo})) and bs.in_type = #{inType} and bs.warehouse_id = #{warehouseId} and ISNULL(NULLIF(bs.warehouse_name, ''), bw.warehouse_name) like concat('%', #{warehouseName}, '%') @@ -115,6 +117,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" out_quantity, stockin_detail_id, stockin_id, + project_no, in_type, warehouse_id, warehouse_name, @@ -142,6 +145,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{outQuantity}, #{stockinDetailId}, #{stockinId}, + #{projectNo}, #{inType}, #{warehouseId}, #{warehouseName}, @@ -172,6 +176,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" out_quantity = #{outQuantity}, stockin_detail_id = #{stockinDetailId}, stockin_id = #{stockinId}, + project_no = #{projectNo}, in_type = #{inType}, warehouse_id = #{warehouseId}, warehouse_name = #{warehouseName}, diff --git a/ruoyi-admin/src/main/resources/mapper/in/InStockinDetailMapper.xml b/ruoyi-admin/src/main/resources/mapper/in/InStockinDetailMapper.xml index ff6506f..e328893 100644 --- a/ruoyi-admin/src/main/resources/mapper/in/InStockinDetailMapper.xml +++ b/ruoyi-admin/src/main/resources/mapper/in/InStockinDetailMapper.xml @@ -25,16 +25,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + select stockin_detail_id, stockin_id, lot_no, product_id, product_name, quantity, scan_num, assign_num, remark, - line_n, item_cod, item_nam, spec_mode, uni, actual_qt, warehous, locatio, inventory_, source_doc_n, remar + line_n, item_cod, item_nam, spec_mode, uni, actual_qt, warehous, locatio, inventory_, source_doc_n, remar, project_no from in_stockin_detail select stockin_detail_id, stockin_id, lot_no, product_id, product_name, quantity, scan_num, assign_num, remark, - line_n, item_cod, item_nam, spec_mode, uni, actual_qt, warehous, locatio, inventory_, source_doc_n, remar + line_n, item_cod, item_nam, spec_mode, uni, actual_qt, warehous, locatio, inventory_, source_doc_n, remar, project_no from in_stockin_detail_finish @@ -50,6 +51,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and assign_num = #{assignNum} and item_cod like concat('%', #{itemCod}, '%') and item_nam like concat('%', #{itemNam}, '%') + and project_no = #{projectNo} @@ -67,6 +69,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and stockin_detail_id = #{stockinDetailId} and product_id = #{productId} and product_name like concat('%', #{productName}, '%') + and project_no = #{projectNo} order by line_n asc, stockin_detail_id asc @@ -83,6 +86,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and assign_num = #{assignNum} and item_cod like concat('%', #{itemCod}, '%') and item_nam like concat('%', #{itemNam}, '%') + and project_no = #{projectNo} @@ -109,6 +113,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" inventory_, source_doc_n, remar, + project_no, #{stockinDetailId}, @@ -131,6 +136,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{inventoryStatus}, #{sourceDocN}, #{remar}, + #{projectNo}, @@ -156,6 +162,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" inventory_ = #{inventoryStatus}, source_doc_n = #{sourceDocN}, remar = #{remar}, + project_no = #{projectNo}, where stockin_detail_id = #{stockinDetailId} diff --git a/ruoyi-admin/src/main/resources/mapper/in/InStockinMapper.xml b/ruoyi-admin/src/main/resources/mapper/in/InStockinMapper.xml index a3c3f94..fa46d09 100644 --- a/ruoyi-admin/src/main/resources/mapper/in/InStockinMapper.xml +++ b/ruoyi-admin/src/main/resources/mapper/in/InStockinMapper.xml @@ -24,6 +24,7 @@ + @@ -47,14 +48,14 @@ select stockin_id, in_type, stock_source, status, create_date, start_date, finish_date, erp_bill, warehouse_id, warehouse_name, user_id, user_name, dept_id, dept_name, supplier_id, supplier_name, - document_n, document_dat, project_nam, counterpart, receiving_or, purchasing_or, receiving_dep, purchasing_dep, + document_n, document_dat, project_nam, project_no, counterpart, receiving_or, purchasing_or, receiving_dep, purchasing_dep, handle, creato, logistics_, logistics_n, warehous, print_coun, tota, reviewe, create_dat, review_dat, receive, receiver_a, receiver_phon, material_re from in_stockin select stockin_id, in_type, stock_source, status, create_date, start_date, finish_date, erp_bill, warehouse_id, warehouse_name, user_id, user_name, dept_id, dept_name, supplier_id, supplier_name, - document_n, document_dat, project_nam, counterpart, receiving_or, purchasing_or, receiving_dep, purchasing_dep, + document_n, document_dat, project_nam, project_no, counterpart, receiving_or, purchasing_or, receiving_dep, purchasing_dep, handle, creato, logistics_, logistics_n, warehous, print_coun, tota, reviewe, create_dat, review_dat, receive, receiver_a, receiver_phon, material_re from in_stockin_finish @@ -75,6 +76,7 @@ and supplier_name like concat('%', #{supplierName}, '%') and document_n like concat('%', #{documentN}, '%') and project_nam like concat('%', #{projectNam}, '%') + and project_no like concat('%', #{projectNo}, '%') and counterpart like concat('%', #{counterpart}, '%') ${params.warehouseScope} @@ -96,6 +98,7 @@ and warehouse_id = #{warehouseId} and warehouse_name like concat('%', #{warehouseName}, '%') and document_n like concat('%', #{documentN}, '%') + and project_no like concat('%', #{projectNo}, '%') and supplier_name like concat('%', #{supplierName}, '%') ${params.warehouseScope} @@ -131,13 +134,14 @@ and supplier_name like concat('%', #{supplierName}, '%') and document_n like concat('%', #{documentN}, '%') and project_nam like concat('%', #{projectNam}, '%') + and project_no like concat('%', #{projectNo}, '%') and counterpart like concat('%', #{counterpart}, '%') ${params.warehouseScope} @@ -49,6 +50,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and customer_name like concat('%', #{customerName}, '%') and supplier_id = #{supplierId} and supplier_name like concat('%', #{supplierName}, '%') + and project_no = #{projectNo} @@ -73,6 +75,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and customer_name like concat('%', #{customerName}, '%') and supplier_id = #{supplierId} and supplier_name like concat('%', #{supplierName}, '%') + and project_no = #{projectNo} @@ -96,6 +99,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" customer_name, supplier_id, supplier_name, + project_no, #{jobDetailId}, @@ -116,6 +120,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{customerName}, #{supplierId}, #{supplierName}, + #{projectNo}, @@ -139,6 +144,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" customer_name = #{customerName}, supplier_id = #{supplierId}, supplier_name = #{supplierName}, + project_no = #{projectNo}, where job_detail_id = #{jobDetailId} diff --git a/ruoyi-admin/src/main/resources/mapper/in/MydoJobMapper.xml b/ruoyi-admin/src/main/resources/mapper/in/MydoJobMapper.xml index b330958..d091c83 100644 --- a/ruoyi-admin/src/main/resources/mapper/in/MydoJobMapper.xml +++ b/ruoyi-admin/src/main/resources/mapper/in/MydoJobMapper.xml @@ -82,6 +82,44 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" where job_id = #{jobId} + + + + + + + + update mydo_job + set entrance_id = #{entranceId}, height = #{height}, status = '3', execution_phase = '1', + start_time = coalesce(start_time, #{startTime}) + where job_id = #{jobId} + and warehouse_id = '03' + and in_or_out < 20 + and status in ('1', '2') + and execution_phase = '0' + + + + + + {call proc_bind_tray_msg( #{v_stockin_detail_id,mode=IN,jdbcType=VARCHAR}, @@ -47,36 +71,592 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" order by roadway_id asc, the_floor asc, the_platoon asc, the_column asc - - update bas_cargo_space - set status = '3', - tray_code = #{v_tray_code} - where cargo_space_id = ( - select top 1 cargo_space_id - from ( - select bcs.cargo_space_id, - bcs.the_platoon, - bcs.the_column, - bcs.the_floor, - count(1) over (partition by bcs.the_platoon) as platoon_empty_count - from bas_cargo_space bcs with (UPDLOCK, READPAST, ROWLOCK) - where bcs.warehouse_id = #{v_warehouse_id} - and bcs.the_type = #{v_cargo_type} - and (#{v_roadway_id} is null or #{v_roadway_id} = '' or bcs.roadway_id = #{v_roadway_id}) - and bcs.status = '1' - and bcs.locked = 'N' - and (bcs.tray_code is null or bcs.tray_code = '') - ) candidate - order by platoon_empty_count desc, the_platoon asc, the_floor asc, the_column asc, cargo_space_id asc + + + + + + + + + + update mydo_job + set roadway_id = #{v_roadway_id} + where job_id = #{v_job_id} + and tray_code = #{v_tray_code} + and warehouse_id = #{v_warehouse_id} + and in_or_out < 20 + and status in ('1', '2', '3') + and (roadway_id is null or ltrim(rtrim(roadway_id)) = '') + and (cargo_space_id is null or ltrim(rtrim(cargo_space_id)) = '') + + + + + + ;with lift_conflict_floors as ( + select distinct outbound_space.the_floor as conflict_floor + from mydo_job inbound_job + inner join mydo_job outbound_job + on outbound_job.warehouse_id = inbound_job.warehouse_id + and outbound_job.exit_id = inbound_job.entrance_id + inner join bas_cargo_space outbound_space + on outbound_space.cargo_space_id = outbound_job.cargo_space_id + where inbound_job.job_id = #{v_job_id} + and outbound_job.in_or_out >= 20 + and outbound_job.status = '3' + and outbound_space.the_floor is not null + ), + available_slots as ( + select bcs.cargo_space_id, + bcs.group_coding, + bcs.the_platoon, + bcs.the_column, + bcs.the_floor, + coalesce(cac.direction_type, '1') as direction_type, + case when bcs.the_height = #{v_height} then 0 else 1 end as height_priority, + case + when upper(ltrim(rtrim(isnull(#{v_entrance_id}, '')))) in ('03-AU-01', '03-AU-03') + then case when bcs.this_cargo_area in ('G', 'H') then 0 else 1 end + when upper(ltrim(rtrim(isnull(#{v_entrance_id}, '')))) in ('03-AU-02', '03-AU-04') + then case when bcs.this_cargo_area in ('A', 'B') then 0 else 1 end + else 0 + end as entrance_area_priority + from bas_cargo_space bcs with (UPDLOCK, READPAST, ROWLOCK) + left join bas_cargo_area_config cac + on cac.warehouse_id = bcs.warehouse_id + and cac.cargo_area_code = bcs.this_cargo_area + and cac.is_active = '1' + where bcs.warehouse_id = #{v_warehouse_id} + and bcs.the_floor = try_convert(bigint, #{v_roadway_id}) + and bcs.the_type = #{v_cargo_type} + and bcs.the_height >= #{v_height} + and bcs.status = '1' + and isnull(bcs.locked, 'N') = 'N' + and (bcs.tray_code is null or ltrim(rtrim(bcs.tray_code)) = '') + and bcs.group_coding is not null + and ltrim(rtrim(bcs.group_coding)) != '' + and ((bcs.this_cargo_area in ('A', 'B') and bcs.the_column between 1 and 6) + or (bcs.this_cargo_area in ('C', 'D') and bcs.the_column between 8 and 12) + or (bcs.this_cargo_area in ('E', 'F') and bcs.the_column between 14 and 18) + or (bcs.this_cargo_area in ('G', 'H') and bcs.the_column between 20 and 22)) + and not exists ( + select 1 + from lift_conflict_floors + where lift_conflict_floors.conflict_floor = bcs.the_floor + ) + ), + stock_profiles as ( + select occupied.cargo_space_id, + occupied.group_coding, + case + when ltrim(rtrim(isnull(bsd.product_id, ''))) = 'KTP0001' then 'EMPTY_TRAY' + when ltrim(rtrim(isnull(coalesce(bsd.project_no, bs.project_no), ''))) != '' + and bs.in_type = '1' then 'RAW_PROJECT' + when ltrim(rtrim(isnull(coalesce(bsd.project_no, bs.project_no), ''))) != '' + and bs.in_type = '2' then 'SEMI_PROJECT' + when ltrim(rtrim(isnull(coalesce(bsd.project_no, bs.project_no), ''))) = '' + and bs.in_type = '1' then 'RAW_PRODUCT' + else 'UNKNOWN' + end as storage_mode, + nullif(ltrim(rtrim(coalesce(bsd.project_no, bs.project_no))), '') as project_no, + nullif(ltrim(rtrim(bsd.product_id)), '') as product_id, + nullif(ltrim(rtrim(bsd.product_name)), '') as product_name + from bas_stock bs + inner join bas_stock_detail bsd on bsd.stock_id = bs.stock_id + inner join bas_cargo_space occupied on occupied.cargo_space_id = bs.cargo_space_id + where bs.warehouse_id = #{v_warehouse_id} + and occupied.the_floor = try_convert(bigint, #{v_roadway_id}) + and occupied.group_coding is not null + and isnull(bsd.quantity, 0) - isnull(bsd.out_quantity, 0) > 0 + ), + assigned_job_profiles as ( + select assigned.cargo_space_id, + assigned.group_coding, + case + when ltrim(rtrim(isnull(jd.product_id, ''))) = 'KTP0001' then 'EMPTY_TRAY' + when ltrim(rtrim(isnull(jd.project_no, ''))) != '' and ins.in_type = '1' then 'RAW_PROJECT' + when ltrim(rtrim(isnull(jd.project_no, ''))) != '' and ins.in_type = '2' then 'SEMI_PROJECT' + when ltrim(rtrim(isnull(jd.project_no, ''))) = '' and ins.in_type = '1' then 'RAW_PRODUCT' + else 'UNKNOWN' + end as storage_mode, + nullif(ltrim(rtrim(jd.project_no)), '') as project_no, + nullif(ltrim(rtrim(jd.product_id)), '') as product_id, + nullif(ltrim(rtrim(jd.product_name)), '') as product_name + from mydo_job j + inner join mydo_job_detail jd on jd.job_id = j.job_id + left join in_stockin ins on ins.stockin_id = jd.stockin_id + inner join bas_cargo_space assigned on assigned.cargo_space_id = j.cargo_space_id + where j.warehouse_id = #{v_warehouse_id} + and assigned.the_floor = try_convert(bigint, #{v_roadway_id}) + and j.in_or_out < 20 + and j.status in ('1', '2', '3') + and j.job_id != #{v_job_id} + and assigned.group_coding is not null + ), + reserved_job_profiles as ( + select reserved.cargo_space_id, + reserved.group_coding, + case + when ltrim(rtrim(isnull(jd.product_id, ''))) = 'KTP0001' then 'EMPTY_TRAY' + when ltrim(rtrim(isnull(jd.project_no, ''))) != '' and ins.in_type = '1' then 'RAW_PROJECT' + when ltrim(rtrim(isnull(jd.project_no, ''))) != '' and ins.in_type = '2' then 'SEMI_PROJECT' + when ltrim(rtrim(isnull(jd.project_no, ''))) = '' and ins.in_type = '1' then 'RAW_PRODUCT' + else 'UNKNOWN' + end as storage_mode, + nullif(ltrim(rtrim(jd.project_no)), '') as project_no, + nullif(ltrim(rtrim(jd.product_id)), '') as product_id, + nullif(ltrim(rtrim(jd.product_name)), '') as product_name + from bas_cargo_space reserved + inner join mydo_job j + on j.warehouse_id = reserved.warehouse_id + and j.tray_code = reserved.tray_code + inner join mydo_job_detail jd on jd.job_id = j.job_id + left join in_stockin ins on ins.stockin_id = jd.stockin_id + where reserved.warehouse_id = #{v_warehouse_id} + and reserved.the_floor = try_convert(bigint, #{v_roadway_id}) + and reserved.status = '3' + and reserved.tray_code is not null + and ltrim(rtrim(reserved.tray_code)) != '' + and reserved.group_coding is not null + and j.in_or_out < 20 + and j.status in ('1', '2', '3') + and j.job_id != #{v_job_id} + ), + profiled_occupancy as ( + select cargo_space_id, group_coding, storage_mode, project_no, product_id, product_name + from stock_profiles + union all + select cargo_space_id, group_coding, storage_mode, project_no, product_id, product_name + from assigned_job_profiles + union all + select cargo_space_id, group_coding, storage_mode, project_no, product_id, product_name + from reserved_job_profiles + ), + occupied_spaces as ( + select bcs.cargo_space_id, bcs.group_coding + from bas_cargo_space bcs + where bcs.warehouse_id = #{v_warehouse_id} + and bcs.the_floor = try_convert(bigint, #{v_roadway_id}) + and bcs.group_coding is not null + and bcs.status not in ('1', '7', '8', '9', '10') + ), + unknown_occupancy as ( + select occupied.cargo_space_id, + occupied.group_coding, + 'UNKNOWN' as storage_mode, + cast(null as nvarchar(80)) as project_no, + cast(null as nvarchar(80)) as product_id, + cast(null as nvarchar(200)) as product_name + from occupied_spaces occupied + where not exists ( + select 1 + from profiled_occupancy profiled + where profiled.cargo_space_id = occupied.cargo_space_id + ) + ), + all_occupancy as ( + select cargo_space_id, group_coding, storage_mode, project_no, product_id, product_name + from profiled_occupancy + union all + select cargo_space_id, group_coding, storage_mode, project_no, product_id, product_name + from unknown_occupancy + ), + eligible_lanes as ( + select lane.group_coding, + case when count(distinct occupancy.cargo_space_id) > 0 then 1 else 0 end as has_occupancy, + count(distinct occupancy.cargo_space_id) as occupied_space_count + from (select distinct group_coding from available_slots) lane + left join all_occupancy occupancy on occupancy.group_coding = lane.group_coding + group by lane.group_coding + having sum(case + when occupancy.cargo_space_id is null then 0 + when #{v_storage_mode} = 'EMPTY_TRAY' + and occupancy.storage_mode = 'EMPTY_TRAY' + and occupancy.product_id = 'KTP0001' then 0 + when #{v_storage_mode} = 'RAW_PRODUCT' + and occupancy.storage_mode = 'RAW_PRODUCT' + and occupancy.project_no is null + and occupancy.product_id = #{v_product_id} + and occupancy.product_name = #{v_product_name} then 0 + when #{v_storage_mode} in ('RAW_PROJECT', 'SEMI_PROJECT') + and occupancy.storage_mode in ('RAW_PROJECT', 'SEMI_PROJECT') + and occupancy.project_no = #{v_project_no} then 0 + else 1 + end) = 0 + ), + lane_candidates as ( + select slots.*, + lanes.has_occupancy, + lanes.occupied_space_count, + abs(convert(bigint, checksum(slots.group_coding, #{v_tray_code}))) as lane_random_key + from available_slots slots + inner join eligible_lanes lanes on lanes.group_coding = slots.group_coding + ), + pending_layer_reservations as ( + select count(*) as pending_count + from mydo_job pending + where pending.warehouse_id = #{v_warehouse_id} + and pending.in_or_out < 20 + and pending.status in ('1', '2', '3') + and pending.job_id != #{v_job_id} + and pending.roadway_id = convert(nvarchar(80), #{v_roadway_id}) + and (pending.cargo_space_id is null or ltrim(rtrim(pending.cargo_space_id)) = '') + ), + candidate_capacity as ( + select count(*) as candidate_count from lane_candidates + ) + update target with (ROWLOCK) + set target.status = '3', + target.tray_code = #{v_tray_code} + from bas_cargo_space target + inner join ( + select top 1 cargo_space_id + from lane_candidates + cross join pending_layer_reservations + cross join candidate_capacity + where candidate_count > pending_count + order by height_priority asc, + entrance_area_priority asc, + case when #{v_storage_mode} = 'EMPTY_TRAY' then lane_random_key end asc, + case when #{v_storage_mode} != 'EMPTY_TRAY' then has_occupancy end desc, + case when #{v_storage_mode} != 'EMPTY_TRAY' then occupied_space_count end desc, + group_coding asc, + the_platoon asc, + the_floor asc, + case when direction_type = '1' then the_column end asc, + case when direction_type = '2' then the_column end desc, + the_column asc, + cargo_space_id asc + ) selected on selected.cargo_space_id = target.cargo_space_id + where target.status = '1' + and isnull(target.locked, 'N') = 'N' + and (target.tray_code is null or ltrim(rtrim(target.tray_code)) = '') + + + update bas_cargo_space + set status = '3', + tray_code = #{v_tray_code} + where cargo_space_id = ( + select top 1 cargo_space_id + from ( + select bcs.cargo_space_id, + bcs.the_platoon, + bcs.the_column, + bcs.the_floor, + coalesce(cac.direction_type, '1') as direction_type, + min(bcs.the_column) over (partition by bcs.warehouse_id, bcs.this_cargo_area) as area_min_column, + max(bcs.the_column) over (partition by bcs.warehouse_id, bcs.this_cargo_area) as area_max_column, + case when bcs.the_height = #{v_height} then 0 else 1 end as height_priority, + count(1) over (partition by bcs.the_platoon) as platoon_empty_count + from bas_cargo_space bcs with (UPDLOCK, READPAST, ROWLOCK) + left join bas_cargo_area_config cac + on cac.warehouse_id = bcs.warehouse_id + and cac.cargo_area_code = bcs.this_cargo_area + and cac.is_active = '1' + where bcs.warehouse_id = #{v_warehouse_id} + and bcs.the_type = #{v_cargo_type} + and (#{v_roadway_id} is null or #{v_roadway_id} = '' or bcs.roadway_id = #{v_roadway_id}) + and bcs.the_height >= #{v_height} + and bcs.status = '1' + and bcs.locked = 'N' + and (bcs.tray_code is null or bcs.tray_code = '') + ) candidate + order by height_priority asc, platoon_empty_count desc, + the_platoon asc, the_floor asc, + case when direction_type = '1' then the_column end asc, + case when direction_type = '2' then the_column end desc, + case when direction_type = '3' then + case + when the_column - area_min_column <= area_max_column - the_column + then the_column - area_min_column + else area_max_column - the_column + end + end asc, + case when direction_type = '3' then the_column end asc, + cargo_space_id asc + ) + + @@ -87,6 +667,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" tray_code = null where warehouse_id = #{v_warehouse_id} and tray_code = #{v_tray_code} + and cargo_space_id = #{v_cargo_space_id} and status = '3' @@ -219,6 +800,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" jd.product_id as materialCode, jd.product_name as materialName, jd.lot_no as lotNo, + jd.project_no as projectNo, jd.stock_quantity as stockQuantity, jd.out_quantity as quantity, isnull(jd.stock_quantity, 0) - isnull(jd.out_quantity, 0) as residualQuantity @@ -235,9 +817,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" jd.product_id as materialCode, jd.product_name as materialName, jd.lot_no as lotNo, + jd.project_no as projectNo, + bs.stockin_id as stockinId, + bs.in_type as inType, jd.stock_quantity as stockQuantity, jd.out_quantity as quantity from mydo_job_detail jd + left join bas_stock bs on bs.stock_id = jd.stock_id where jd.job_id = #{jobId} order by jd.job_detail_id @@ -274,6 +860,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" bsd.product_name as productName, bsd.product_name as materialName, bsd.lot_no as lotNo, + bsd.project_no as projectNo, isnull(bsd.quantity, 0) as quantity, bs.tray_code as trayCode, bs.warehouse_id as warehouseId, @@ -300,6 +887,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" bsd.product_name as productName, bsd.product_name as materialName, bsd.lot_no as lotNo, + bsd.project_no as projectNo, isnull(bsd.quantity, 0) as quantity, isnull(bsd.out_quantity, 0) as outQuantity, isnull(bsd.quantity, 0) - isnull(bsd.out_quantity, 0) as availableQuantity, @@ -370,6 +958,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" bsd.lot_no as lotNo, bsd.product_id as productId, bsd.product_name as productName, + bsd.project_no as projectNo, isnull(bsd.quantity, 0) - isnull(bsd.out_quantity, 0) as availableQuantity, bs.tray_code as trayCode, bs.warehouse_id as warehouseId, diff --git a/ruoyi-admin/src/test/java/com/ruoyi/api/client/WcsClientTest.java b/ruoyi-admin/src/test/java/com/ruoyi/api/client/WcsClientTest.java index c06b750..010ae0b 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/api/client/WcsClientTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/api/client/WcsClientTest.java @@ -124,6 +124,26 @@ class WcsClientTest { assertThat(response.getMessage()).isEqualTo("WCS拒绝: 旧成功码"); } + @Test + void jsonJobsUseWarehouseSpecificWcsBaseUrls() { + RecordingWcsClient client = new RecordingWcsClient(false); + client.externalProp.getSystem("WCS").setWarehouseBaseUrls(Map.of( + "01", "http://wcs-01", + "02", "http://wcs-02")); + + MydoJob warehouse01Job = inboundJob(); + warehouse01Job.setWarehouseId("01"); + MydoJob warehouse02Job = inboundJob(); + warehouse02Job.setWarehouseId("02"); + + client.sendPutawayTask(warehouse01Job); + client.sendPutawayTask(warehouse02Job); + + assertThat(client.calls).extracting(Call::url).containsExactly( + "http://wcs-01/wcs/api/receiveWmsJob", + "http://wcs-02/wcs/api/receiveWmsJob"); + } + private static class NoPostWcsClient extends WcsClient { private final ExternalProp externalProp; diff --git a/ruoyi-admin/src/test/java/com/ruoyi/api/controller/ExternalApiControllerWcsCodeTest.java b/ruoyi-admin/src/test/java/com/ruoyi/api/controller/ExternalApiControllerWcsCodeTest.java index 336e070..03aa60a 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/api/controller/ExternalApiControllerWcsCodeTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/api/controller/ExternalApiControllerWcsCodeTest.java @@ -3,6 +3,8 @@ package com.ruoyi.api.controller; import com.ruoyi.api.client.WcsClient; import com.ruoyi.api.domain.ApiResponse; import com.ruoyi.api.domain.WcsInboundCompleteRequest; +import com.ruoyi.api.domain.WcsInboundLocationRequest; +import com.ruoyi.api.domain.WcsDenseInboundJobRequest; import com.ruoyi.api.domain.WcsJobExceptionRequest; import com.ruoyi.api.domain.WcsOutboundCompleteRequest; import com.ruoyi.common.core.domain.AjaxResult; @@ -11,16 +13,25 @@ import com.ruoyi.in.job.service.IMydoJobService; import com.ruoyi.pda.service.PdaService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.math.BigDecimal; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -68,6 +79,41 @@ class ExternalApiControllerWcsCodeTest { assertWcsError(result, "WCS拒绝任务"); } + @Test + void denseWcsCanClaimInboundJobByTrayEntranceAndHeight() { + WcsDenseInboundJobRequest request = new WcsDenseInboundJobRequest(); + request.setTrayCode("TP03-01"); + request.setEntranceId("IN03-01"); + request.setHeight(BigDecimal.valueOf(2)); + MydoJob job = new MydoJob(); + job.setJobId("RK03-01"); + job.setTrayCode("TP03-01"); + job.setWarehouseId("03"); + job.setExecutionPhase("1"); + when(mydoJobService.claimDenseInboundJob("TP03-01", "IN03-01", BigDecimal.valueOf(2))) + .thenReturn(job); + + AjaxResult result = controller.claimDenseInboundJob(request); + + assertWcsSuccess(result); + MydoJob responseJob = (MydoJob) result.get(AjaxResult.DATA_TAG); + assertThat(responseJob).isNotSameAs(job); + assertThat(responseJob.getExecutionPhase()).isEqualTo("0"); + assertThat(job.getExecutionPhase()).isEqualTo("1"); + } + + @Test + void denseWcsClaimReturnsCodeOneForBusinessValidationFailure() { + WcsDenseInboundJobRequest request = new WcsDenseInboundJobRequest(); + request.setTrayCode("TP03-404"); + request.setEntranceId("IN03-01"); + request.setHeight(BigDecimal.ONE); + when(mydoJobService.claimDenseInboundJob("TP03-404", "IN03-01", BigDecimal.ONE)) + .thenThrow(new RuntimeException("未找到对应托盘入库作业")); + + assertWcsError(controller.claimDenseInboundJob(request), "未找到对应托盘入库作业"); + } + @Test void wcsCallbackEndpointsReturnCodeZeroWhenBusinessHandlingSucceeds() { MydoJob inboundJob = new MydoJob(); @@ -135,6 +181,608 @@ class ExternalApiControllerWcsCodeTest { assertWcsError(controller.handleWcsException(request), "异常类型不能为空"); } + @Test + void inboundLocationRejectsMissingHeightBeforeJobLookup() { + WcsInboundLocationRequest request = new WcsInboundLocationRequest(); + request.setJobId("RK001"); + + assertWcsError(controller.inboundLocation(request), "height必须为1或2"); + + verifyNoInteractions(mydoJobService, pdaService); + } + + @Test + void inboundLocationRejectsUnsupportedHeightBeforeJobLookup() { + WcsInboundLocationRequest request = new WcsInboundLocationRequest(); + request.setJobId("RK001"); + request.setHeight(new BigDecimal("3")); + + assertWcsError(controller.inboundLocation(request), "height必须为1或2"); + + verifyNoInteractions(mydoJobService, pdaService); + } + + @Test + void warehouse03FirstStagePrioritizesCurrentLayerAndOnlyPersistsRoadway() { + WcsInboundLocationRequest request = denseRequest(1, List.of(1, 2, 3), List.of(2, 5)); + MydoJob job = denseInboundJob(null); + when(mydoJobService.selectMydoJobByJobId("RK03")).thenReturn(job); + when(pdaService.planInboundLayer("TP03", "03", "RK03", "03-AU-01", List.of(2, 1, 3), BigDecimal.ONE)) + .thenReturn(Map.of("roadway_id", "2", "selected_layer", 2)); + + AjaxResult result = controller.inboundLocation(request); + + assertWcsSuccess(result); + assertThat(job.getRoadwayId()).isEqualTo("2"); + assertThat(job.getCargoSpaceId()).isNull(); + assertThat(job.getHeight()).isNull(); + verify(pdaService).planInboundLayer("TP03", "03", "RK03", "03-AU-01", List.of(2, 1, 3), BigDecimal.ONE); + verify(pdaService, never()).lockInboundCargoSpace(any(), any(), any(), any()); + verify(mydoJobService, never()).assignInboundCargoSpaceIfUnassigned(any(MydoJob.class)); + verify(mydoJobService, never()).updateMydoJob(any(MydoJob.class)); + } + + @Test + void warehouse03FirstStageRetryReturnsSelectedLayerWithoutLockingLocation() { + WcsInboundLocationRequest request = denseRequest(1, List.of(1, 2, 3), List.of(1)); + MydoJob job = denseInboundJob("2"); + when(mydoJobService.selectMydoJobByJobId("RK03")).thenReturn(job); + + AjaxResult result = controller.inboundLocation(request); + + assertWcsSuccess(result); + assertThat(result.get(AjaxResult.DATA_TAG)).isSameAs(job); + verifyNoInteractions(pdaService); + verify(mydoJobService, never()).assignInboundCargoSpaceIfUnassigned(any(MydoJob.class)); + } + + @Test + void warehouse03SecondStageAllocatesOnlyOnPersistedAvailableLayer() { + WcsInboundLocationRequest request = denseRequest(2, List.of(1, 2, 3), List.of(1)); + MydoJob job = denseInboundJob("2"); + when(mydoJobService.selectMydoJobByJobId("RK03")).thenReturn(job); + when(pdaService.lockInboundCargoSpace("TP03", "03", "2", "03-AU-01", BigDecimal.ONE)) + .thenReturn(Map.of("roadway_id", "2", "cargo_space_id", "03020801", "cargo_space_name", "03020801")); + when(mydoJobService.assignInboundCargoSpaceIfUnassigned(job)).thenReturn(1); + + AjaxResult result = controller.inboundLocation(request); + + assertWcsSuccess(result); + assertThat(job.getRoadwayId()).isEqualTo("2"); + assertThat(job.getCargoSpaceId()).isEqualTo("03020801"); + assertThat(job.getHeight()).isEqualByComparingTo(BigDecimal.ONE); + verify(pdaService).lockInboundCargoSpace("TP03", "03", "2", "03-AU-01", BigDecimal.ONE); + verify(mydoJobService).assignInboundCargoSpaceIfUnassigned(job); + } + + @Test + void warehouse03SecondStageRejectsLayerNoLongerAvailable() { + WcsInboundLocationRequest request = denseRequest(2, List.of(1, 3), List.of(1)); + MydoJob job = denseInboundJob("2"); + when(mydoJobService.selectMydoJobByJobId("RK03")).thenReturn(job); + + assertWcsError(controller.inboundLocation(request), "已选层2不在本次可用层中"); + + verifyNoInteractions(pdaService); + verify(mydoJobService, never()).assignInboundCargoSpaceIfUnassigned(any(MydoJob.class)); + } + + @Test + void warehouse03RejectsMissingOrInvalidLayerArraysAndStage() { + WcsInboundLocationRequest missing = new WcsInboundLocationRequest(); + missing.setJobId("RK03"); + missing.setHeight(BigDecimal.ONE); + MydoJob job = denseInboundJob(null); + when(mydoJobService.selectMydoJobByJobId("RK03")).thenReturn(job); + + assertWcsError(controller.inboundLocation(missing), "availableLayers不能为空"); + + WcsInboundLocationRequest invalid = denseRequest(3, List.of(0, 6), List.of(6)); + assertWcsError(controller.inboundLocation(invalid), "allocationStage必须为1或2"); + verifyNoInteractions(pdaService); + } + + @Test + void inboundLocationPersistsRequestedHeightOnFirstAllocation() { + WcsInboundLocationRequest request = new WcsInboundLocationRequest(); + request.setJobId("RK001"); + request.setHeight(BigDecimal.ONE); + + MydoJob job = new MydoJob(); + job.setJobId("RK001"); + job.setInOrOut(1L); + job.setStatus("1"); + job.setTrayCode("TP001"); + job.setWarehouseId("WH001"); + job.setRoadwayId("R01"); + when(mydoJobService.selectMydoJobByJobId("RK001")).thenReturn(job); + when(pdaService.lockInboundCargoSpace("TP001", "WH001", "R01", BigDecimal.ONE)) + .thenReturn(Map.of( + "roadway_id", "R01", + "cargo_space_id", "CS001", + "cargo_space_name", "A0101")); + when(mydoJobService.assignInboundCargoSpaceIfUnassigned(job)).thenReturn(1); + + AjaxResult result = controller.inboundLocation(request); + + assertWcsSuccess(result); + assertThat(result.get(AjaxResult.DATA_TAG)).isSameAs(job); + verify(pdaService).lockInboundCargoSpace("TP001", "WH001", "R01", BigDecimal.ONE); + ArgumentCaptor jobCaptor = ArgumentCaptor.forClass(MydoJob.class); + verify(mydoJobService).assignInboundCargoSpaceIfUnassigned(jobCaptor.capture()); + assertThat(jobCaptor.getValue().getRoadwayId()).isEqualTo("R01"); + assertThat(jobCaptor.getValue().getCargoSpaceId()).isEqualTo("CS001"); + assertThat(jobCaptor.getValue().getCargoSpaceName()).isEqualTo("A0101"); + assertThat(jobCaptor.getValue().getHeight()).isEqualByComparingTo(BigDecimal.ONE); + verify(mydoJobService, never()).updateMydoJob(any(MydoJob.class)); + } + + @Test + void inboundLocationReturnsAlreadyAssignedJobWhenHeightMatches() { + WcsInboundLocationRequest request = new WcsInboundLocationRequest(); + request.setJobId("RK001"); + request.setHeight(new BigDecimal("1.00")); + + MydoJob job = new MydoJob(); + job.setJobId("RK001"); + job.setInOrOut(1L); + job.setStatus("1"); + job.setExecutionPhase("1"); + job.setCargoSpaceId("CS001"); + job.setHeight(BigDecimal.ONE); + when(mydoJobService.selectMydoJobByJobId("RK001")).thenReturn(job); + + AjaxResult result = controller.inboundLocation(request); + + assertWcsSuccess(result); + assertThat(result.get(AjaxResult.DATA_TAG)).isSameAs(job); + assertThat(((MydoJob) result.get(AjaxResult.DATA_TAG)).getExecutionPhase()).isEqualTo("1"); + verifyNoInteractions(pdaService); + verify(mydoJobService, never()).assignInboundCargoSpaceIfUnassigned(job); + } + + @Test + void inboundLocationRejectsAlreadyAssignedOutboundJobEvenWhenHeightMatches() { + WcsInboundLocationRequest request = new WcsInboundLocationRequest(); + request.setJobId("CK001"); + request.setHeight(BigDecimal.ONE); + + MydoJob job = new MydoJob(); + job.setJobId("CK001"); + job.setInOrOut(20L); + job.setStatus("1"); + job.setCargoSpaceId("CS001"); + job.setHeight(BigDecimal.ONE); + when(mydoJobService.selectMydoJobByJobId("CK001")).thenReturn(job); + + AjaxResult result = controller.inboundLocation(request); + + assertWcsError(result, "非法入库作业或作业状态不允许分配货位"); + verifyNoInteractions(pdaService); + verify(mydoJobService, never()).assignInboundCargoSpaceIfUnassigned(job); + } + + @Test + void inboundLocationRejectsInactiveInboundJobBeforeLockingCargoSpace() { + WcsInboundLocationRequest request = new WcsInboundLocationRequest(); + request.setJobId("RK001"); + request.setHeight(BigDecimal.ONE); + + MydoJob job = new MydoJob(); + job.setJobId("RK001"); + job.setInOrOut(1L); + job.setStatus("4"); + job.setTrayCode("TP001"); + job.setWarehouseId("WH001"); + job.setRoadwayId("R01"); + when(mydoJobService.selectMydoJobByJobId("RK001")).thenReturn(job); + lenient().when(pdaService.lockInboundCargoSpace("TP001", "WH001", "R01", BigDecimal.ONE)) + .thenReturn(Map.of( + "roadway_id", "R01", + "cargo_space_id", "CS001", + "cargo_space_name", "A0101")); + lenient().when(mydoJobService.assignInboundCargoSpaceIfUnassigned(job)).thenReturn(1); + + AjaxResult result = controller.inboundLocation(request); + + assertWcsError(result, "非法入库作业或作业状态不允许分配货位"); + verifyNoInteractions(pdaService); + verify(mydoJobService, never()).assignInboundCargoSpaceIfUnassigned(job); + } + + @Test + void inboundLocationRejectsAlreadyAssignedJobWhenHeightDiffers() { + WcsInboundLocationRequest request = new WcsInboundLocationRequest(); + request.setJobId("RK001"); + request.setHeight(new BigDecimal("2.0")); + + MydoJob job = new MydoJob(); + job.setJobId("RK001"); + job.setInOrOut(1L); + job.setStatus("1"); + job.setCargoSpaceId("CS001"); + job.setHeight(BigDecimal.ONE); + when(mydoJobService.selectMydoJobByJobId("RK001")).thenReturn(job); + + assertWcsError(controller.inboundLocation(request), "请求height与已分配作业不一致"); + + verifyNoInteractions(pdaService); + verify(mydoJobService, never()).assignInboundCargoSpaceIfUnassigned(job); + } + + @Test + void inboundLocationRejectsAlreadyAssignedJobWithInvalidPersistedHeight() { + WcsInboundLocationRequest request = new WcsInboundLocationRequest(); + request.setJobId("RK001"); + request.setHeight(BigDecimal.ONE); + + MydoJob job = new MydoJob(); + job.setJobId("RK001"); + job.setInOrOut(1L); + job.setStatus("1"); + job.setCargoSpaceId("CS001"); + when(mydoJobService.selectMydoJobByJobId("RK001")).thenReturn(job); + + assertWcsError(controller.inboundLocation(request), "已分配入库作业height无效"); + + verifyNoInteractions(pdaService); + verify(mydoJobService, never()).assignInboundCargoSpaceIfUnassigned(job); + } + + @Test + void inboundLocationReleasesExactCandidateWhenAssignmentCannotBeConfirmed() { + WcsInboundLocationRequest request = new WcsInboundLocationRequest(); + request.setJobId("RK001"); + request.setHeight(BigDecimal.ONE); + + MydoJob job = new MydoJob(); + job.setJobId("RK001"); + job.setInOrOut(1L); + job.setStatus("1"); + job.setTrayCode("TP001"); + job.setWarehouseId("WH001"); + job.setRoadwayId("R01"); + when(mydoJobService.selectMydoJobByJobId("RK001")).thenReturn(job).thenReturn(null); + when(pdaService.lockInboundCargoSpace("TP001", "WH001", "R01", BigDecimal.ONE)) + .thenReturn(Map.of( + "roadway_id", "R01", + "cargo_space_id", "CS-CANDIDATE", + "cargo_space_name", "A0102")); + when(pdaService.releaseInboundCargoSpace("TP001", "WH001", "CS-CANDIDATE")) + .thenReturn(1); + + AjaxResult result = controller.inboundLocation(request); + + assertWcsError(result, "入库货位回写作业失败"); + verify(mydoJobService).assignInboundCargoSpaceIfUnassigned(job); + verify(mydoJobService, times(2)).selectMydoJobByJobId("RK001"); + verify(mydoJobService, never()).updateMydoJob(any(MydoJob.class)); + verify(pdaService).lockInboundCargoSpace("TP001", "WH001", "R01", BigDecimal.ONE); + verify(pdaService).releaseInboundCargoSpace("TP001", "WH001", "CS-CANDIDATE"); + verifyNoMoreInteractions(pdaService); + } + + @Test + void inboundLocationTreatsZeroRowReleaseAsSuccessfulIdempotentCleanup() { + WcsInboundLocationRequest request = new WcsInboundLocationRequest(); + request.setJobId("RK001"); + request.setHeight(BigDecimal.ONE); + + MydoJob job = new MydoJob(); + job.setJobId("RK001"); + job.setInOrOut(1L); + job.setStatus("1"); + job.setTrayCode("TP001"); + job.setWarehouseId("WH001"); + job.setRoadwayId("R01"); + when(mydoJobService.selectMydoJobByJobId("RK001")).thenReturn(job).thenReturn(null); + when(pdaService.lockInboundCargoSpace("TP001", "WH001", "R01", BigDecimal.ONE)) + .thenReturn(Map.of( + "roadway_id", "R01", + "cargo_space_id", "CS-CANDIDATE", + "cargo_space_name", "A0102")); + when(pdaService.releaseInboundCargoSpace("TP001", "WH001", "CS-CANDIDATE")) + .thenReturn(0); + + AjaxResult result = controller.inboundLocation(request); + + assertWcsError(result, "入库货位回写作业失败"); + verify(mydoJobService).assignInboundCargoSpaceIfUnassigned(job); + verify(pdaService).releaseInboundCargoSpace("TP001", "WH001", "CS-CANDIDATE"); + } + + @Test + void inboundLocationReturnsReleaseFailureWhenPreciseCleanupThrows() { + WcsInboundLocationRequest request = new WcsInboundLocationRequest(); + request.setJobId("RK001"); + request.setHeight(BigDecimal.ONE); + + MydoJob job = new MydoJob(); + job.setJobId("RK001"); + job.setInOrOut(1L); + job.setStatus("1"); + job.setTrayCode("TP001"); + job.setWarehouseId("WH001"); + job.setRoadwayId("R01"); + when(mydoJobService.selectMydoJobByJobId("RK001")).thenReturn(job).thenReturn(null); + when(pdaService.lockInboundCargoSpace("TP001", "WH001", "R01", BigDecimal.ONE)) + .thenReturn(Map.of( + "roadway_id", "R01", + "cargo_space_id", "CS-CANDIDATE", + "cargo_space_name", "A0102")); + when(pdaService.releaseInboundCargoSpace("TP001", "WH001", "CS-CANDIDATE")) + .thenThrow(new RuntimeException("database release failed")); + + AtomicReference result = new AtomicReference<>(); + assertThatCode(() -> result.set(controller.inboundLocation(request))) + .doesNotThrowAnyException(); + + assertWcsError(result.get(), "入库候选货位释放失败"); + verify(pdaService).releaseInboundCargoSpace("TP001", "WH001", "CS-CANDIDATE"); + } + + @Test + void inboundLocationReleasesLosingCandidateAndRejectsConflictingRaceWinner() { + WcsInboundLocationRequest request = new WcsInboundLocationRequest(); + request.setJobId("RK001"); + request.setHeight(new BigDecimal("2.0")); + + MydoJob job = new MydoJob(); + job.setJobId("RK001"); + job.setInOrOut(1L); + job.setStatus("1"); + job.setTrayCode("TP001"); + job.setWarehouseId("WH001"); + job.setRoadwayId("R01"); + MydoJob winner = new MydoJob(); + winner.setJobId("RK001"); + winner.setInOrOut(1L); + winner.setStatus("1"); + winner.setTrayCode("TP001"); + winner.setWarehouseId("WH001"); + winner.setRoadwayId("R01"); + winner.setCargoSpaceId("CS-WINNER"); + winner.setCargoSpaceName("A0101"); + winner.setHeight(BigDecimal.ONE); + when(mydoJobService.selectMydoJobByJobId("RK001")).thenReturn(job, winner); + when(pdaService.lockInboundCargoSpace("TP001", "WH001", "R01", new BigDecimal("2.0"))) + .thenReturn(Map.of( + "roadway_id", "R01", + "cargo_space_id", "CS-LOSER", + "cargo_space_name", "A0202")); + when(pdaService.releaseInboundCargoSpace("TP001", "WH001", "CS-LOSER")) + .thenReturn(1); + + AjaxResult result = controller.inboundLocation(request); + + assertWcsError(result, "请求height与已分配作业不一致"); + verify(mydoJobService).assignInboundCargoSpaceIfUnassigned(job); + verify(mydoJobService, never()).updateMydoJob(any(MydoJob.class)); + verify(pdaService).releaseInboundCargoSpace("TP001", "WH001", "CS-LOSER"); + verify(pdaService, never()).releaseInboundCargoSpace("TP001", "WH001", "CS-WINNER"); + assertThat(winner.getRoadwayId()).isEqualTo("R01"); + assertThat(winner.getCargoSpaceId()).isEqualTo("CS-WINNER"); + assertThat(winner.getCargoSpaceName()).isEqualTo("A0101"); + assertThat(winner.getHeight()).isEqualByComparingTo(BigDecimal.ONE); + } + + @Test + void inboundLocationReleasesLosingCandidateAndReturnsSameHeightRaceWinner() { + WcsInboundLocationRequest request = new WcsInboundLocationRequest(); + request.setJobId("RK001"); + request.setHeight(new BigDecimal("1.00")); + + MydoJob job = new MydoJob(); + job.setJobId("RK001"); + job.setInOrOut(1L); + job.setStatus("1"); + job.setTrayCode("TP001"); + job.setWarehouseId("WH001"); + job.setRoadwayId("R01"); + MydoJob winner = new MydoJob(); + winner.setJobId("RK001"); + winner.setInOrOut(1L); + winner.setStatus("1"); + winner.setTrayCode("TP001"); + winner.setWarehouseId("WH001"); + winner.setRoadwayId("R01"); + winner.setCargoSpaceId("CS-WINNER"); + winner.setCargoSpaceName("A0101"); + winner.setHeight(BigDecimal.ONE); + when(mydoJobService.selectMydoJobByJobId("RK001")).thenReturn(job, winner); + when(pdaService.lockInboundCargoSpace("TP001", "WH001", "R01", new BigDecimal("1.00"))) + .thenReturn(Map.of( + "roadway_id", "R01", + "cargo_space_id", "CS-LOSER", + "cargo_space_name", "A0202")); + when(pdaService.releaseInboundCargoSpace("TP001", "WH001", "CS-LOSER")) + .thenReturn(0); + + AjaxResult result = controller.inboundLocation(request); + + assertWcsSuccess(result); + assertThat(result.get(AjaxResult.DATA_TAG)).isSameAs(winner); + verify(mydoJobService).assignInboundCargoSpaceIfUnassigned(job); + verify(pdaService).releaseInboundCargoSpace("TP001", "WH001", "CS-LOSER"); + verify(pdaService, never()).releaseInboundCargoSpace("TP001", "WH001", "CS-WINNER"); + } + + @Test + void inboundLocationRejectsReloadedOutboundWinnerAfterCleaningLosingCandidate() { + WcsInboundLocationRequest request = new WcsInboundLocationRequest(); + request.setJobId("RK001"); + request.setHeight(BigDecimal.ONE); + + MydoJob job = new MydoJob(); + job.setJobId("RK001"); + job.setInOrOut(1L); + job.setStatus("1"); + job.setTrayCode("TP001"); + job.setWarehouseId("WH001"); + job.setRoadwayId("R01"); + MydoJob winner = new MydoJob(); + winner.setJobId("RK001"); + winner.setInOrOut(20L); + winner.setStatus("1"); + winner.setTrayCode("TP001"); + winner.setWarehouseId("WH001"); + winner.setRoadwayId("R01"); + winner.setCargoSpaceId("CS-WINNER"); + winner.setCargoSpaceName("A0101"); + winner.setHeight(BigDecimal.ONE); + when(mydoJobService.selectMydoJobByJobId("RK001")).thenReturn(job, winner); + when(pdaService.lockInboundCargoSpace("TP001", "WH001", "R01", BigDecimal.ONE)) + .thenReturn(Map.of( + "roadway_id", "R01", + "cargo_space_id", "CS-LOSER", + "cargo_space_name", "A0202")); + when(pdaService.releaseInboundCargoSpace("TP001", "WH001", "CS-LOSER")) + .thenReturn(1); + + AjaxResult result = controller.inboundLocation(request); + + assertWcsError(result, "非法入库作业或作业状态不允许分配货位"); + verify(pdaService).releaseInboundCargoSpace("TP001", "WH001", "CS-LOSER"); + verify(pdaService, never()).releaseInboundCargoSpace("TP001", "WH001", "CS-WINNER"); + } + + @Test + void inboundLocationRejectsReloadedInactiveWinnerAfterCleaningLosingCandidate() { + WcsInboundLocationRequest request = new WcsInboundLocationRequest(); + request.setJobId("RK001"); + request.setHeight(BigDecimal.ONE); + + MydoJob job = new MydoJob(); + job.setJobId("RK001"); + job.setInOrOut(1L); + job.setStatus("1"); + job.setTrayCode("TP001"); + job.setWarehouseId("WH001"); + job.setRoadwayId("R01"); + MydoJob winner = new MydoJob(); + winner.setJobId("RK001"); + winner.setInOrOut(1L); + winner.setStatus("4"); + winner.setTrayCode("TP001"); + winner.setWarehouseId("WH001"); + winner.setRoadwayId("R01"); + winner.setCargoSpaceId("CS-WINNER"); + winner.setCargoSpaceName("A0101"); + winner.setHeight(BigDecimal.ONE); + when(mydoJobService.selectMydoJobByJobId("RK001")).thenReturn(job, winner); + when(pdaService.lockInboundCargoSpace("TP001", "WH001", "R01", BigDecimal.ONE)) + .thenReturn(Map.of( + "roadway_id", "R01", + "cargo_space_id", "CS-LOSER", + "cargo_space_name", "A0202")); + when(pdaService.releaseInboundCargoSpace("TP001", "WH001", "CS-LOSER")) + .thenReturn(1); + + AjaxResult result = controller.inboundLocation(request); + + assertWcsError(result, "非法入库作业或作业状态不允许分配货位"); + verify(pdaService).releaseInboundCargoSpace("TP001", "WH001", "CS-LOSER"); + verify(pdaService, never()).releaseInboundCargoSpace("TP001", "WH001", "CS-WINNER"); + } + + @Test + void inboundLocationRejectsDifferentHeightForSameCandidateWithoutReleasingWinner() { + WcsInboundLocationRequest request = new WcsInboundLocationRequest(); + request.setJobId("RK001"); + request.setHeight(new BigDecimal("2.0")); + + MydoJob job = new MydoJob(); + job.setJobId("RK001"); + job.setInOrOut(1L); + job.setStatus("1"); + job.setTrayCode("TP001"); + job.setWarehouseId("WH001"); + job.setRoadwayId("R01"); + MydoJob winner = new MydoJob(); + winner.setJobId("RK001"); + winner.setInOrOut(1L); + winner.setStatus("1"); + winner.setTrayCode("TP001"); + winner.setWarehouseId("WH001"); + winner.setRoadwayId("R01"); + winner.setCargoSpaceId("CS-SHARED"); + winner.setCargoSpaceName("A0101"); + winner.setHeight(BigDecimal.ONE); + when(mydoJobService.selectMydoJobByJobId("RK001")).thenReturn(job, winner); + when(pdaService.lockInboundCargoSpace("TP001", "WH001", "R01", new BigDecimal("2.0"))) + .thenReturn(Map.of( + "roadway_id", "R01", + "cargo_space_id", "CS-SHARED", + "cargo_space_name", "A0101")); + + AjaxResult result = controller.inboundLocation(request); + + assertWcsError(result, "请求height与已分配作业不一致"); + verify(mydoJobService).assignInboundCargoSpaceIfUnassigned(job); + verify(pdaService, never()).releaseInboundCargoSpace("TP001", "WH001", "CS-SHARED"); + } + + @Test + void inboundLocationReturnsSameHeightRaceWinnerWithoutReleasingWinnerCandidate() { + WcsInboundLocationRequest request = new WcsInboundLocationRequest(); + request.setJobId("RK001"); + request.setHeight(new BigDecimal("1.00")); + + MydoJob job = new MydoJob(); + job.setJobId("RK001"); + job.setInOrOut(1L); + job.setStatus("1"); + job.setTrayCode("TP001"); + job.setWarehouseId("WH001"); + job.setRoadwayId("R01"); + MydoJob winner = new MydoJob(); + winner.setJobId("RK001"); + winner.setInOrOut(1L); + winner.setStatus("1"); + winner.setTrayCode("TP001"); + winner.setWarehouseId("WH001"); + winner.setRoadwayId("R01"); + winner.setCargoSpaceId("CS-SHARED"); + winner.setCargoSpaceName("A0101"); + winner.setHeight(BigDecimal.ONE); + when(mydoJobService.selectMydoJobByJobId("RK001")).thenReturn(job, winner); + when(pdaService.lockInboundCargoSpace("TP001", "WH001", "R01", new BigDecimal("1.00"))) + .thenReturn(Map.of( + "roadway_id", "R01", + "cargo_space_id", "CS-SHARED", + "cargo_space_name", "A0101")); + + AjaxResult result = controller.inboundLocation(request); + + assertWcsSuccess(result); + assertThat(result.get(AjaxResult.DATA_TAG)).isSameAs(winner); + verify(mydoJobService).assignInboundCargoSpaceIfUnassigned(job); + verify(mydoJobService, never()).updateMydoJob(any(MydoJob.class)); + verify(pdaService, never()).releaseInboundCargoSpace("TP001", "WH001", "CS-SHARED"); + } + + private WcsInboundLocationRequest denseRequest(Integer stage, List available, List current) { + WcsInboundLocationRequest request = new WcsInboundLocationRequest(); + request.setJobId("RK03"); + request.setHeight(BigDecimal.ONE); + request.setAllocationStage(stage); + request.setAvailableLayers(available); + request.setCurrentLayers(current); + return request; + } + + private MydoJob denseInboundJob(String selectedLayer) { + MydoJob job = new MydoJob(); + job.setJobId("RK03"); + job.setInOrOut(1L); + job.setStatus("1"); + job.setTrayCode("TP03"); + job.setWarehouseId("03"); + job.setEntranceId("03-AU-01"); + job.setRoadwayId(selectedLayer); + return job; + } + private void assertWcsSuccess(AjaxResult result) { assertThat(result.get(AjaxResult.CODE_TAG)).isEqualTo(0); } diff --git a/ruoyi-admin/src/test/java/com/ruoyi/api/domain/WcsInboundLocationRequestTest.java b/ruoyi-admin/src/test/java/com/ruoyi/api/domain/WcsInboundLocationRequestTest.java new file mode 100644 index 0000000..c45fef8 --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/api/domain/WcsInboundLocationRequestTest.java @@ -0,0 +1,40 @@ +package com.ruoyi.api.domain; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class WcsInboundLocationRequestTest { + + @Test + void layerPropertiesUseOrderedArrayContracts() throws Exception { + WcsInboundLocationRequest request = new WcsInboundLocationRequest(); + Method availableSetter = WcsInboundLocationRequest.class.getMethod("setAvailableLayers", List.class); + Method currentSetter = WcsInboundLocationRequest.class.getMethod("setCurrentLayers", List.class); + Method stageSetter = WcsInboundLocationRequest.class.getMethod("setAllocationStage", Integer.class); + + availableSetter.invoke(request, List.of(1, 2, 3)); + currentSetter.invoke(request, List.of(2)); + stageSetter.invoke(request, 1); + + assertThat(request.getAvailableLayers()).containsExactly(1, 2, 3); + assertThat(request.getCurrentLayers()).containsExactly(2); + assertThat(request.getAllocationStage()).isEqualTo(1); + } + + @Test + void heightPropertyUsesBigDecimalContract() throws Exception { + WcsInboundLocationRequest request = new WcsInboundLocationRequest(); + Method setter = WcsInboundLocationRequest.class.getMethod("setHeight", BigDecimal.class); + Method getter = WcsInboundLocationRequest.class.getMethod("getHeight"); + BigDecimal height = new BigDecimal("2"); + + setter.invoke(request, height); + + assertThat(getter.invoke(request)).isEqualTo(height); + } +} diff --git a/ruoyi-admin/src/test/java/com/ruoyi/bas/cargospace/mapper/BasCargoSpaceMapperSqlTest.java b/ruoyi-admin/src/test/java/com/ruoyi/bas/cargospace/mapper/BasCargoSpaceMapperSqlTest.java index 3d1e202..04afb7a 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/bas/cargospace/mapper/BasCargoSpaceMapperSqlTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/bas/cargospace/mapper/BasCargoSpaceMapperSqlTest.java @@ -48,8 +48,127 @@ class BasCargoSpaceMapperSqlTest { String selectSql = section(xml, ""); + + assertThat(selectSql) + .contains("left join bas_cargo_area_config cac") + .contains("coalesce(cac.direction_type, '1') as direction_type") + .contains("area_min_column") + .contains("area_max_column") + .contains("case when direction_type = '1' then the_column end asc") + .contains("case when direction_type = '2' then the_column end desc") + .contains("case when direction_type = '3'"); + } + + @Test + void putOccupiedReallocationLocksAndConditionallyClaimsItsCandidate() throws Exception { + String xml = Files.readString(resolveMapperXml()); + String selectSql = section(xml, + ""); + + assertThat(selectSql) + .contains("") + .contains("bcs.group_coding is not null") + .contains("bcs.this_cargo_area in ('A', 'B') and bcs.the_column between 1 and 6") + .contains("bcs.this_cargo_area in ('G', 'H') and bcs.the_column between 20 and 22") + .contains("profiled_occupancy") + .contains("unknown_occupancy") + .contains("from bas_stock bs") + .contains("inner join bas_stock_detail bsd") + .contains("from mydo_job j") + .contains("inner join mydo_job_detail jd") + .contains("j.job_id != #{jobId}") + .contains("#{storageMode}") + .contains("#{projectNo}") + .contains("#{productId}") + .contains("#{productName}") + .contains("'EMPTY_TRAY'") + .contains("'RAW_PRODUCT'") + .contains("'RAW_PROJECT', 'SEMI_PROJECT'") + .contains("eligible_lanes") + .contains("case when direction_type = '1' then the_column end asc") + .contains("case when direction_type = '2' then the_column end desc"); + } + + @Test + void warehouse03PutOccupiedReallocationTreatsRoadwayAsSelectedFloor() throws Exception { + String xml = Files.readString(resolveMapperXml()); + String selectSql = section(xml, + ""); + + assertThat(selectSql) + .contains("#{entranceId}") + .contains("03-AU-01") + .contains("03-AU-03") + .contains("03-AU-02") + .contains("03-AU-04") + .contains("bcs.this_cargo_area in ('G', 'H')") + .contains("bcs.this_cargo_area in ('A', 'B')") + .contains("entrance_area_priority") + .contains("height_priority asc") + .contains("entrance_area_priority asc"); + } + + @Test + void warehouse03PutOccupiedReallocationExcludesExecutingOutboundConflictFloor() throws Exception { + String xml = Files.readString(resolveMapperXml()); + String selectSql = section(xml, + ""); + + assertThat(lockSql) + .contains("resultType=\"java.lang.Integer\"") + .contains("#{jobId}") + .contains("sys.sp_getapplock") + .contains("@LockMode = 'Exclusive'") + .contains("@LockOwner = 'Transaction'") + .contains("@LockTimeout = 10000"); + } + + @Test + void putOccupiedReallocationUsesTransactionOwnedRoadwayPoolLock() throws Exception { + String xml = Files.readString(resolveMapperXml()); + String lockSql = section(xml, + ""); + assertThat(lockedSql).contains("the_height"); + } + + @Test + void inboundCargoSpaceLockUsesConfiguredCargoAreaDirection() throws Exception { + String xml = Files.readString(resolveMapperXml()); + String lockSql = section(xml, ""); + + assertThat(lockSql) + .contains("left join bas_cargo_area_config cac") + .contains("coalesce(cac.direction_type, '1') as direction_type") + .contains("min(bcs.the_column) over (partition by bcs.warehouse_id, bcs.this_cargo_area) as area_min_column") + .contains("max(bcs.the_column) over (partition by bcs.warehouse_id, bcs.this_cargo_area) as area_max_column") + .contains("case when direction_type = '1' then the_column end asc") + .contains("case when direction_type = '2' then the_column end desc") + .contains("case when direction_type = '3'"); + } + + @Test + void inboundCargoSpaceLookupSerializesAllocationByTray() throws Exception { + String xml = Files.readString(resolveMapperXml()); + String lookupSql = section(xml, + ""); + + assertThat(applicationLockSql) + .contains("resultType=\"java.lang.Integer\"") + .contains("nvarchar(255)") + .contains("wms:inbound-cargo-space:") + .containsIgnoringCase("HASHBYTES('SHA2_256'") + .contains("#{v_warehouse_id}") + .contains("#{v_tray_code}") + .containsIgnoringCase("datalength(@warehouse_id)") + .containsIgnoringCase("datalength(@tray_code)") + .contains("sys.sp_getapplock") + .contains("@LockMode = 'Exclusive'") + .contains("@LockOwner = 'Transaction'") + .contains("@LockTimeout = 10000") + .contains("@DbPrincipal = N'public'") + .contains("select @lock_result") + .doesNotContain("#{v_roadway_id}") + .doesNotContain("#{v_height}") + .doesNotContainIgnoringCase("NOCOUNT"); + } + + @Test + void warehouse03AllocationUsesTransactionOwnedRoadwayLock() throws Exception { + String xml = Files.readString(resolveMapperXml()); + String lockSql = section(xml, + ""); + String lockSql = section(xml, ""); + + assertEntranceAreaPriority(plannerSql); + assertEntranceAreaPriority(lockSql); + } + + @Test + void warehouse03LayerPlannerUsesSameInventoryJobAndLaneCapacityRulesWithoutUpdatingLocation() throws Exception { + String xml = Files.readString(resolveMapperXml()); + String plannerSql = section(xml, + ""); + + assertThat(lockSql) + .contains("bcs.the_floor = try_convert(bigint, #{v_roadway_id})") + .contains("pending_layer_reservations") + .contains("candidate_count > pending_count"); + assertThat(lockedSql).contains("the_floor"); + } + + @Test + void warehouse03AllocationExcludesFloorUsedByExecutingOutboundToCurrentInboundPort() throws Exception { + String xml = Files.readString(resolveMapperXml()); + String plannerSql = section(xml, + ""); + + assertThat(lockSql) + .contains("wms:inbound-layer-planning:") + .contains("#{v_warehouse_id}") + .contains("sys.sp_getapplock") + .contains("@LockMode = 'Exclusive'") + .contains("@LockOwner = 'Transaction'") + .contains("@LockTimeout = 10000"); + } + + @Test + void inboundCargoSpaceReleaseTargetsExactCandidateRow() throws Exception { + String xml = Files.readString(resolveMapperXml()); + String releaseSql = section(xml, + ""); + + assertThat(releaseSql) + .contains("warehouse_id = #{v_warehouse_id}") + .contains("tray_code = #{v_tray_code}") + .contains("cargo_space_id = #{v_cargo_space_id}") + .contains("status = '3'"); } @Test @@ -105,7 +370,10 @@ class PdaMapperSqlTest { .contains("j.execution_phase as executionPhase"); assertThat(materialsSql) .contains("jd.in_or_out_d_id as stockpdDetailId") - .contains("jd.stockin_detail_id as stockDetailId"); + .contains("jd.stockin_detail_id as stockDetailId") + .contains("left join bas_stock bs on bs.stock_id = jd.stock_id") + .contains("bs.stockin_id as stockinId") + .contains("bs.in_type as inType"); } private Path resolveMapperXml() { @@ -118,6 +386,20 @@ class PdaMapperSqlTest { .orElseThrow(); } + private void assertEntranceAreaPriority(String sql) { + assertThat(sql) + .contains("#{v_entrance_id}") + .contains("03-AU-01") + .contains("03-AU-03") + .contains("03-AU-02") + .contains("03-AU-04") + .contains("bcs.this_cargo_area in ('G', 'H')") + .contains("bcs.this_cargo_area in ('A', 'B')") + .contains("entrance_area_priority") + .contains("height_priority asc") + .contains("entrance_area_priority asc"); + } + private String section(String text, String startMarker, String endMarker) { int start = text.indexOf(startMarker); assertThat(start).isGreaterThanOrEqualTo(0); @@ -125,4 +407,12 @@ class PdaMapperSqlTest { assertThat(end).isGreaterThan(start); return text.substring(start, end); } + + private Method findPublicMethod(Class type, String name, Class... parameterTypes) { + return Arrays.stream(type.getMethods()) + .filter(method -> method.getName().equals(name)) + .filter(method -> Arrays.equals(method.getParameterTypes(), parameterTypes)) + .findFirst() + .orElse(null); + } } diff --git a/ruoyi-admin/src/test/java/com/ruoyi/pda/mapper/Warehouse03TwoStageMigrationSqlTest.java b/ruoyi-admin/src/test/java/com/ruoyi/pda/mapper/Warehouse03TwoStageMigrationSqlTest.java new file mode 100644 index 0000000..aeaa775 --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/pda/mapper/Warehouse03TwoStageMigrationSqlTest.java @@ -0,0 +1,39 @@ +package com.ruoyi.pda.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 Warehouse03TwoStageMigrationSqlTest { + + @Test + void clearsOnlyActiveUnassignedWarehouse03InboundRoadwayValues() throws Exception { + String sql = Files.readString(resolveSql()).toLowerCase(); + + assertThat(sql) + .contains("set xact_abort on") + .contains("begin transaction") + .contains("update dbo.mydo_job") + .contains("set roadway_id = null") + .contains("where warehouse_id = n'03'") + .contains("and in_or_out < 20") + .contains("and status in (n'1', n'2', n'3')") + .contains("cargo_space_id is null") + .contains("ltrim(rtrim(cargo_space_id)) = n''") + .contains("commit transaction") + .contains("rollback transaction"); + } + + private Path resolveSql() { + return Stream.of( + Path.of("sql/20260714_warehouse_03_two_stage_layer_allocation.sql"), + Path.of("../sql/20260714_warehouse_03_two_stage_layer_allocation.sql")) + .filter(Files::exists) + .findFirst() + .orElseThrow(); + } +} diff --git a/ruoyi-admin/src/test/java/com/ruoyi/pda/service/impl/PdaServiceImplTest.java b/ruoyi-admin/src/test/java/com/ruoyi/pda/service/impl/PdaServiceImplTest.java index 374bc09..71bdd4e 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/pda/service/impl/PdaServiceImplTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/pda/service/impl/PdaServiceImplTest.java @@ -12,6 +12,7 @@ import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.core.domain.model.LoginUser; import com.ruoyi.in.job.domain.MydoJob; +import com.ruoyi.in.job.domain.InboundStorageProfile; import com.ruoyi.in.job.service.IMydoJobService; import com.ruoyi.in.jobdetail.domain.MydoJobDetail; import com.ruoyi.in.jobdetail.service.IMydoJobDetailService; @@ -21,37 +22,373 @@ import com.ruoyi.pd.commit.mapper.PdCommitRecordMapper; import com.ruoyi.pd.diff.mapper.PdDifferenceRecordMapper; import com.ruoyi.pd.stockpddetail.mapper.PdStockpdDetailMapper; import com.ruoyi.pda.domain.AddInJobRequest; +import com.ruoyi.pda.domain.BindTrayRequest; import com.ruoyi.pda.domain.EmptyTrayOutboundRequest; import com.ruoyi.pda.mapper.PdaMapper; +import com.ruoyi.pda.service.PdaService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; +import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.transaction.annotation.Transactional; +import java.lang.reflect.Method; import java.math.BigDecimal; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Map; 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.doAnswer; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class PdaServiceImplTest { + @Test + void inboundCargoSpaceLockContractAcceptsEntranceAndHeightLevel() { + Method method = findPublicMethod(PdaService.class, "lockInboundCargoSpace", + String.class, String.class, String.class, String.class, BigDecimal.class); + + assertThat(method).isNotNull(); + assertThat(method.getReturnType()).isEqualTo(Map.class); + } + + @Test + void inboundCargoSpaceLockContractKeepsTraySerializationInOneTransaction() { + Method method = findPublicMethod(PdaServiceImpl.class, "lockInboundCargoSpace", + String.class, String.class, String.class, String.class, BigDecimal.class); + + assertThat(method).isNotNull(); + assertThat(method.getAnnotation(Transactional.class)).isNotNull(); + } + + @Test + void inboundLayerPlanningContractRunsInOneTransaction() { + Method method = findPublicMethod(PdaServiceImpl.class, "planInboundLayer", + String.class, String.class, String.class, String.class, List.class, BigDecimal.class); + + assertThat(method).isNotNull(); + assertThat(method.getAnnotation(Transactional.class)).isNotNull(); + } + + @Test + void warehouse03LayerPlanningFallsBackInProvidedPriorityOrderAndOnlyAssignsRoadway() { + HashMap rule = new HashMap<>(); + rule.put("allowed_cargo_type", "1"); + when(pdaMapper.selectMatchedTrayRule(any(HashMap.class))).thenReturn(rule); + when(pdaMapper.acquireInboundCargoSpaceAllocationLock(any(HashMap.class))).thenReturn(0); + when(pdaMapper.acquireInboundLayerPlanningLock(any(HashMap.class))).thenReturn(0); + when(mydoJobService.resolveInboundStorageProfile("TP03", "03")) + .thenReturn(InboundStorageProfile.rawProduct("RK03", "1", "P001", "原料甲")); + when(pdaMapper.selectAvailableInboundLayerCandidate(any(HashMap.class))).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + HashMap params = invocation.getArgument(0); + return "1".equals(String.valueOf(params.get("v_roadway_id"))) + ? new HashMap<>(Map.of("selected_layer", 1, "cargo_space_id", "03010101")) + : null; + }); + when(pdaMapper.assignInboundLayerIfUnassigned(any(HashMap.class))).thenReturn(1); + + Map result = service.planInboundLayer( + "TP03", "03", "RK03", "03-AU-01", List.of(2, 1, 3), BigDecimal.ONE); + + assertThat(result).containsEntry("roadway_id", "1").containsEntry("selected_layer", 1); + ArgumentCaptor> candidate = ArgumentCaptor.forClass(HashMap.class); + verify(pdaMapper, times(2)).selectAvailableInboundLayerCandidate(candidate.capture()); + assertThat(candidate.getAllValues()) + .extracting(map -> String.valueOf(map.get("v_roadway_id"))) + .containsExactly("2", "1"); + ArgumentCaptor> assignment = ArgumentCaptor.forClass(HashMap.class); + verify(pdaMapper).assignInboundLayerIfUnassigned(assignment.capture()); + assertThat(assignment.getValue()) + .containsEntry("v_job_id", "RK03") + .containsEntry("v_tray_code", "TP03") + .containsEntry("v_warehouse_id", "03") + .containsEntry("v_entrance_id", "03-AU-01") + .containsEntry("v_roadway_id", "1"); + verify(pdaMapper, never()).lockInboundCargoSpace(any(HashMap.class)); + } + + @Test + void warehouse03LayerPlanningRejectsWhenNoAvailableLayerHasCompatibleCapacity() { + HashMap rule = new HashMap<>(); + rule.put("allowed_cargo_type", "1"); + when(pdaMapper.selectMatchedTrayRule(any(HashMap.class))).thenReturn(rule); + when(pdaMapper.acquireInboundCargoSpaceAllocationLock(any(HashMap.class))).thenReturn(0); + when(pdaMapper.acquireInboundLayerPlanningLock(any(HashMap.class))).thenReturn(0); + when(mydoJobService.resolveInboundStorageProfile("TP03", "03")) + .thenReturn(InboundStorageProfile.emptyTray("RK03")); + when(pdaMapper.selectAvailableInboundLayerCandidate(any(HashMap.class))).thenReturn(null); + + assertThatThrownBy(() -> service.planInboundLayer( + "TP03", "03", "RK03", List.of(2, 1), BigDecimal.ONE)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("可用层均无符合通道规则的货位"); + + verify(pdaMapper, never()).assignInboundLayerIfUnassigned(any(HashMap.class)); + verify(pdaMapper, never()).lockInboundCargoSpace(any(HashMap.class)); + } + + @Test + void inboundCargoSpaceLockAcquiresApplicationLockBeforeLockedTrayLookup() { + HashMap rule = new HashMap<>(); + rule.put("allowed_cargo_type", "1"); + HashMap locked = new HashMap<>(); + locked.put("cargo_space_id", "CS001"); + locked.put("roadway_id", "R01"); + locked.put("the_height", 1.0D); + when(pdaMapper.selectMatchedTrayRule(any(HashMap.class))).thenReturn(rule); + when(pdaMapper.acquireInboundCargoSpaceAllocationLock(any(HashMap.class))).thenReturn(0); + when(pdaMapper.selectLockedCargoSpaceByTrayCode(any(HashMap.class))).thenReturn(locked); + + service.lockInboundCargoSpace("TP001", "WH001", "R01", BigDecimal.ONE); + + InOrder inOrder = inOrder(pdaMapper); + inOrder.verify(pdaMapper).acquireInboundCargoSpaceAllocationLock(any(HashMap.class)); + inOrder.verify(pdaMapper).selectLockedCargoSpaceByTrayCode(any(HashMap.class)); + } + + @Test + void inboundCargoSpaceLockRejectsNegativeApplicationLockResultBeforeLookup() { + HashMap rule = new HashMap<>(); + rule.put("allowed_cargo_type", "1"); + when(pdaMapper.selectMatchedTrayRule(any(HashMap.class))).thenReturn(rule); + when(pdaMapper.acquireInboundCargoSpaceAllocationLock(any(HashMap.class))).thenReturn(-1); + + assertThatThrownBy(() -> service.lockInboundCargoSpace( + "TP001", "WH001", "R01", BigDecimal.ONE)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("入库货位分配锁获取失败"); + + verify(pdaMapper, never()).selectLockedCargoSpaceByTrayCode(any(HashMap.class)); + verify(pdaMapper, never()).lockInboundCargoSpace(any(HashMap.class)); + } + + @Test + void inboundCargoSpaceLockRejectsNullApplicationLockResultBeforeLookup() { + HashMap rule = new HashMap<>(); + rule.put("allowed_cargo_type", "1"); + when(pdaMapper.selectMatchedTrayRule(any(HashMap.class))).thenReturn(rule); + when(pdaMapper.acquireInboundCargoSpaceAllocationLock(any(HashMap.class))).thenReturn(null); + + assertThatThrownBy(() -> service.lockInboundCargoSpace( + "TP001", "WH001", "R01", BigDecimal.ONE)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("入库货位分配锁获取失败"); + + verify(pdaMapper, never()).selectLockedCargoSpaceByTrayCode(any(HashMap.class)); + verify(pdaMapper, never()).lockInboundCargoSpace(any(HashMap.class)); + } + + @Test + void inboundCargoSpaceReleaseContractRequiresAndForwardsCandidateId() throws Exception { + Method method = findPublicMethod(PdaService.class, "releaseInboundCargoSpace", + String.class, String.class, String.class); + + assertThat(method).isNotNull(); + assertThat(method.getReturnType()).isEqualTo(int.class); + when(pdaMapper.releaseInboundCargoSpace(any(HashMap.class))).thenReturn(1); + + assertThat(method.invoke(service, "TP001", "WH001", "CS001")).isEqualTo(1); + + ArgumentCaptor> mapCaptor = ArgumentCaptor.forClass(HashMap.class); + verify(pdaMapper).releaseInboundCargoSpace(mapCaptor.capture()); + assertThat(mapCaptor.getValue()) + .containsEntry("v_tray_code", "TP001") + .containsEntry("v_warehouse_id", "WH001") + .containsEntry("v_cargo_space_id", "CS001"); + } + + @Test + void inboundCargoSpaceLockReusesExistingLockInRequestedRoadway() { + HashMap rule = new HashMap<>(); + rule.put("allowed_cargo_type", "1"); + HashMap locked = new HashMap<>(); + locked.put("cargo_space_id", "CS001"); + locked.put("cargo_space_name", "A0101"); + locked.put("roadway_id", "R01"); + locked.put("the_height", 1.0D); + when(pdaMapper.selectMatchedTrayRule(any(HashMap.class))).thenReturn(rule); + when(pdaMapper.acquireInboundCargoSpaceAllocationLock(any(HashMap.class))).thenReturn(1); + when(pdaMapper.selectLockedCargoSpaceByTrayCode(any(HashMap.class))).thenReturn(locked); + + Map result = service.lockInboundCargoSpace( + "TP001", "WH001", "R01", BigDecimal.ONE); + + assertThat(result).isSameAs(locked); + verify(pdaMapper, never()).lockInboundCargoSpace(any(HashMap.class)); + } + + @Test + void inboundCargoSpaceLockRejectsExistingLockInAnotherRoadway() { + HashMap rule = new HashMap<>(); + rule.put("allowed_cargo_type", "1"); + HashMap locked = new HashMap<>(); + locked.put("cargo_space_id", "CS002"); + locked.put("cargo_space_name", "B0101"); + locked.put("roadway_id", "R02"); + when(pdaMapper.selectMatchedTrayRule(any(HashMap.class))).thenReturn(rule); + when(pdaMapper.acquireInboundCargoSpaceAllocationLock(any(HashMap.class))).thenReturn(0); + when(pdaMapper.selectLockedCargoSpaceByTrayCode(any(HashMap.class))).thenReturn(locked); + + assertThatThrownBy(() -> service.lockInboundCargoSpace( + "TP001", "WH001", "R01", BigDecimal.ONE)) + .isInstanceOf(RuntimeException.class) + .hasMessage("托盘已在其他巷道锁定货位"); + + verify(pdaMapper, never()).lockInboundCargoSpace(any(HashMap.class)); + } + + @Test + void lockInboundCargoSpacePassesHeightAndReusesCompatibleLock() { + HashMap rule = new HashMap<>(); + rule.put("allowed_cargo_type", "A"); + when(pdaMapper.selectMatchedTrayRule(any(HashMap.class))).thenReturn(rule); + when(pdaMapper.acquireInboundCargoSpaceAllocationLock(any(HashMap.class))).thenReturn(0); + HashMap locked = new HashMap<>(); + locked.put("cargo_space_id", "CS-HIGH"); + locked.put("roadway_id", "R01"); + locked.put("the_height", 2.0D); + when(pdaMapper.selectLockedCargoSpaceByTrayCode(any(HashMap.class))).thenReturn(locked); + + Map result = service.lockInboundCargoSpace( + "A001", "01", "R01", BigDecimal.ONE); + + assertThat(result).isSameAs(locked); + ArgumentCaptor> captor = ArgumentCaptor.forClass(HashMap.class); + verify(pdaMapper).selectLockedCargoSpaceByTrayCode(captor.capture()); + assertThat(captor.getValue()).containsEntry("v_height", BigDecimal.ONE); + verify(pdaMapper, never()).lockInboundCargoSpace(any(HashMap.class)); + } + + @Test + void lockInboundCargoSpaceRejectsIncompatibleExistingLock() { + HashMap rule = new HashMap<>(); + rule.put("allowed_cargo_type", "A"); + when(pdaMapper.selectMatchedTrayRule(any(HashMap.class))).thenReturn(rule); + when(pdaMapper.acquireInboundCargoSpaceAllocationLock(any(HashMap.class))).thenReturn(0); + HashMap locked = new HashMap<>(); + locked.put("cargo_space_id", "CS-NORMAL"); + locked.put("roadway_id", "R01"); + locked.put("the_height", 1.0D); + when(pdaMapper.selectLockedCargoSpaceByTrayCode(any(HashMap.class))).thenReturn(locked); + + assertThatThrownBy(() -> service.lockInboundCargoSpace( + "A001", "01", "R01", BigDecimal.valueOf(2))) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("已锁定货位高度不满足"); + + verify(pdaMapper, never()).lockInboundCargoSpace(any(HashMap.class)); + } + + @Test + void lockInboundCargoSpaceRejectsUnsupportedHeight() { + assertThatThrownBy(() -> service.lockInboundCargoSpace( + "A001", "01", "R01", BigDecimal.ZERO)) + .isInstanceOf(RuntimeException.class) + .hasMessage("height必须为1或2"); + + verifyNoInteractions(pdaMapper); + } + + @Test + void warehouse03LockResolvesProfileAndSerializesRoadwayBeforeAllocation() { + HashMap rule = new HashMap<>(); + rule.put("allowed_cargo_type", "1"); + HashMap locked = new HashMap<>(); + locked.put("cargo_space_id", "03010101"); + locked.put("cargo_space_name", "03010101"); + locked.put("roadway_id", "R01"); + locked.put("the_floor", 1); + locked.put("the_height", 2.0D); + InboundStorageProfile profile = InboundStorageProfile.rawProject("RK001", "1", "PRJ-01"); + when(pdaMapper.selectMatchedTrayRule(any(HashMap.class))).thenReturn(rule); + when(pdaMapper.acquireInboundCargoSpaceAllocationLock(any(HashMap.class))).thenReturn(0); + when(pdaMapper.selectLockedCargoSpaceByTrayCode(any(HashMap.class))).thenReturn(null, locked); + when(mydoJobService.resolveInboundStorageProfile("TP001", "03")).thenReturn(profile); + when(pdaMapper.acquireInboundLayerPlanningLock(any(HashMap.class))).thenReturn(0); + when(pdaMapper.acquireInboundRoadwayAllocationLock(any(HashMap.class))).thenReturn(0); + when(pdaMapper.lockInboundCargoSpace(any(HashMap.class))).thenReturn(1); + + Map result = service.lockInboundCargoSpace( + "TP001", "03", "1", "03-AU-01", BigDecimal.ONE); + + assertThat(result).isSameAs(locked); + assertThat(result).containsEntry("roadway_id", "1"); + InOrder inOrder = inOrder(pdaMapper, mydoJobService); + inOrder.verify(pdaMapper).acquireInboundCargoSpaceAllocationLock(any(HashMap.class)); + inOrder.verify(pdaMapper).selectLockedCargoSpaceByTrayCode(any(HashMap.class)); + inOrder.verify(mydoJobService).resolveInboundStorageProfile("TP001", "03"); + inOrder.verify(pdaMapper).acquireInboundLayerPlanningLock(any(HashMap.class)); + inOrder.verify(pdaMapper).acquireInboundRoadwayAllocationLock(any(HashMap.class)); + ArgumentCaptor> candidate = ArgumentCaptor.forClass(HashMap.class); + inOrder.verify(pdaMapper).lockInboundCargoSpace(candidate.capture()); + assertThat(candidate.getValue()) + .containsEntry("v_job_id", "RK001") + .containsEntry("v_entrance_id", "03-AU-01") + .containsEntry("v_storage_mode", InboundStorageProfile.RAW_PROJECT) + .containsEntry("v_project_no", "PRJ-01") + .containsEntry("v_in_type", "1"); + } + + @Test + void warehouse03LockRejectsRoadwayLockFailureBeforeCandidateUpdate() { + HashMap rule = new HashMap<>(); + rule.put("allowed_cargo_type", "1"); + when(pdaMapper.selectMatchedTrayRule(any(HashMap.class))).thenReturn(rule); + when(pdaMapper.acquireInboundCargoSpaceAllocationLock(any(HashMap.class))).thenReturn(0); + when(pdaMapper.selectLockedCargoSpaceByTrayCode(any(HashMap.class))).thenReturn(null); + when(mydoJobService.resolveInboundStorageProfile("TP001", "03")) + .thenReturn(InboundStorageProfile.emptyTray("RK001")); + when(pdaMapper.acquireInboundLayerPlanningLock(any(HashMap.class))).thenReturn(0); + when(pdaMapper.acquireInboundRoadwayAllocationLock(any(HashMap.class))).thenReturn(-1); + + assertThatThrownBy(() -> service.lockInboundCargoSpace( + "TP001", "03", "1", BigDecimal.ONE)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("入库巷道分配锁获取失败"); + + verify(pdaMapper, never()).lockInboundCargoSpace(any(HashMap.class)); + } + + @Test + void otherWarehouseKeepsExistingAllocationWithoutDenseProfileOrRoadwayLock() { + HashMap rule = new HashMap<>(); + rule.put("allowed_cargo_type", "1"); + HashMap locked = new HashMap<>(); + locked.put("cargo_space_id", "01010101"); + locked.put("roadway_id", "R01"); + locked.put("the_height", 1.0D); + when(pdaMapper.selectMatchedTrayRule(any(HashMap.class))).thenReturn(rule); + when(pdaMapper.acquireInboundCargoSpaceAllocationLock(any(HashMap.class))).thenReturn(0); + when(pdaMapper.selectLockedCargoSpaceByTrayCode(any(HashMap.class))).thenReturn(null, locked); + when(pdaMapper.lockInboundCargoSpace(any(HashMap.class))).thenReturn(1); + + service.lockInboundCargoSpace("TP001", "01", "R01", BigDecimal.ONE); + + verifyNoInteractions(mydoJobService); + verify(pdaMapper, never()).acquireInboundRoadwayAllocationLock(any(HashMap.class)); + } + @Mock private PdaMapper pdaMapper; @@ -153,6 +490,98 @@ class PdaServiceImplTest { org.mockito.ArgumentMatchers.argThat(update -> "3".equals(update.getStatus()))); } + @Test + void bindTrayAndAddInJobClearsPendingTrayBindingsBeforeBindingCurrentRequest() { + BindTrayRequest request = new BindTrayRequest(); + request.setTrayCode("A001"); + request.setWarehouseId("WH01"); + request.setStationPointId("IN01"); + BindTrayRequest.BindTrayItem item = new BindTrayRequest.BindTrayItem(); + item.setStockinDetailId("RKD001-1"); + item.setProductId("P001"); + item.setQuantity(new BigDecimal("10")); + request.setItems(List.of(item)); + MydoJob job = stubSuccessfulInboundJobSetup(); + when(wcsClient.sendPutawayTask(job)).thenReturn(ApiResponse.ok("OK")); + doAnswer(invocation -> { + @SuppressWarnings("unchecked") + HashMap map = invocation.getArgument(0); + map.put("v_result", "1"); + map.put("v_resultmsg", "绑盘成功"); + return null; + }).when(pdaMapper).bindTray(any(HashMap.class)); + + service.bindTrayAndAddInJob(request); + + InOrder inOrder = inOrder(pdaMapper); + inOrder.verify(pdaMapper).rollbackPendingTrayBindings(any(HashMap.class)); + inOrder.verify(pdaMapper).deletePendingTrayBindings(any(HashMap.class)); + inOrder.verify(pdaMapper).bindTray(any(HashMap.class)); + inOrder.verify(pdaMapper).addInJob(any(HashMap.class)); + verify(basStationPointService).selectBasStationPointByPointId("IN01"); + verify(basStationPointService).occupyBasStationPoint("IN01"); + } + + @Test + void nonDenseWarehouseBindRequiresInboundStationPoint() { + BindTrayRequest request = new BindTrayRequest(); + request.setTrayCode("A002"); + request.setWarehouseId("01"); + BindTrayRequest.BindTrayItem item = new BindTrayRequest.BindTrayItem(); + item.setStockinDetailId("RKD002-1"); + item.setProductId("P002"); + item.setQuantity(BigDecimal.ONE); + request.setItems(List.of(item)); + + assertThatThrownBy(() -> service.bindTrayAndAddInJob(request)) + .isInstanceOf(RuntimeException.class) + .hasMessage("请选择入库点位"); + verifyNoInteractions(pdaMapper, basStationPointService, wcsClient); + } + + @Test + void denseWarehouseBindDoesNotRequireInboundStationPoint() { + BindTrayRequest request = new BindTrayRequest(); + request.setTrayCode("B003"); + request.setWarehouseId("03"); + BindTrayRequest.BindTrayItem item = new BindTrayRequest.BindTrayItem(); + item.setStockinDetailId("RKD003-1"); + item.setProductId("P003"); + item.setQuantity(BigDecimal.ONE); + request.setItems(List.of(item)); + HashMap trayRule = new HashMap<>(); + trayRule.put("allowed_cargo_type", "B"); + when(pdaMapper.selectMatchedTrayRule(any(HashMap.class))).thenReturn(trayRule); + doAnswer(invocation -> { + @SuppressWarnings("unchecked") + HashMap map = invocation.getArgument(0); + map.put("v_result", "1"); + map.put("v_resultmsg", "操作成功"); + return null; + }).when(pdaMapper).bindTray(any(HashMap.class)); + doAnswer(invocation -> { + @SuppressWarnings("unchecked") + HashMap map = invocation.getArgument(0); + map.put("v_result", "1"); + map.put("v_resultmsg", "生成作业成功"); + return null; + }).when(pdaMapper).addInJob(any(HashMap.class)); + MydoJob job = new MydoJob(); + job.setJobId("RK03-BIND"); + job.setTrayCode("B003"); + job.setWarehouseId("03"); + when(mydoJobService.selectLatestInMydoJobByTrayCodeAndWarehouseId("B003", "03")).thenReturn(job); + when(mydoJobService.updateMydoJob(job)).thenReturn(1); + when(mydoJobDetailService.selectMydoJobDetailList(any(MydoJobDetail.class))).thenReturn(Collections.emptyList()); + + String result = service.bindTrayAndAddInJob(request); + + assertThat(result).contains("等待WCS获取"); + verifyNoInteractions(basStationPointService, wcsClient); + assertThat(job.getEntranceId()).isEmpty(); + assertThat(job.getStatus()).isEqualTo("2"); + } + @Test void addEmptyTrayInboundJobCreatesEmptyContainerDetailAndDispatchesToWcs() { AddInJobRequest request = new AddInJobRequest(); @@ -204,6 +633,72 @@ class PdaServiceImplTest { assertThat(updateCaptor.getValue().getExecutionPhase()).isEqualTo("1"); } + @Test + void warehouse03EmptyTrayInboundStartsWithoutSelectedLayer() { + AddInJobRequest request = new AddInJobRequest(); + request.setTrayCode("B001"); + request.setWarehouseId("03"); + request.setStationPointId("IN03"); + stubWarehouse03InboundStation(); + when(jobIdService.getId("RK")).thenReturn("RK03-EMPTY"); + when(taskNoService.getTaskNo("6")).thenReturn("TASK03-EMPTY"); + when(mydoJobService.insertMydoJob(any(MydoJob.class))).thenReturn(1); + when(mydoJobDetailService.insertMydoJobDetail(any(MydoJobDetail.class))).thenReturn(1); + String result = service.addEmptyTrayInboundJob(request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MydoJob.class); + verify(mydoJobService).insertMydoJob(captor.capture()); + assertThat(captor.getValue().getRoadwayId()).isEmpty(); + assertThat(captor.getValue().getCargoSpaceId()).isNull(); + assertThat(captor.getValue().getStatus()).isEqualTo("2"); + assertThat(captor.getValue().getExecutionPhase()).isEqualTo("0"); + assertThat(result).contains("密集库作业等待WCS获取"); + verify(wcsClient, never()).sendPutawayTask(any(MydoJob.class)); + verify(mydoJobService, never()).updateMydoJob(any(MydoJob.class)); + } + + @Test + void warehouse03NormalInboundInitializationClearsPhysicalRoadwayBeforeWcsPlanning() { + AddInJobRequest request = new AddInJobRequest(); + request.setTrayCode("B002"); + request.setWarehouseId("03"); + doAnswer(invocation -> { + @SuppressWarnings("unchecked") + HashMap map = invocation.getArgument(0); + map.put("v_result", "1"); + map.put("v_resultmsg", "生成作业成功"); + return null; + }).when(pdaMapper).addInJob(any(HashMap.class)); + MydoJob job = new MydoJob(); + job.setJobId("RK03-NORMAL"); + job.setTaskNo("TASK03-NORMAL"); + job.setTrayCode("B002"); + job.setWarehouseId("03"); + job.setRoadwayId("1"); + when(mydoJobService.selectLatestInMydoJobByTrayCodeAndWarehouseId("B002", "03")).thenReturn(job); + when(mydoJobService.updateMydoJob(any(MydoJob.class))).thenReturn(1); + when(mydoJobDetailService.selectMydoJobDetailList(any(MydoJobDetail.class))).thenReturn(Collections.emptyList()); + String result = service.addInJob(request); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(MydoJob.class); + verify(mydoJobService).updateMydoJob(updateCaptor.capture()); + assertThat(updateCaptor.getValue().getRoadwayId()).isEmpty(); + assertThat(updateCaptor.getValue().getCargoSpaceId()).isEmpty(); + assertThat(updateCaptor.getValue().getEntranceId()).isEmpty(); + assertThat(updateCaptor.getValue().getStatus()).isEqualTo("2"); + assertThat(updateCaptor.getValue().getExecutionPhase()).isEqualTo("0"); + assertThat(result).contains("等待WCS获取"); + verify(wcsClient, never()).sendPutawayTask(job); + } + + private Method findPublicMethod(Class type, String name, Class... parameterTypes) { + return Arrays.stream(type.getMethods()) + .filter(method -> method.getName().equals(name)) + .filter(method -> Arrays.equals(method.getParameterTypes(), parameterTypes)) + .findFirst() + .orElse(null); + } + @Test void emptyTrayOutboundCountRequiresWarehouseAndReturnsTrayTypeCounts() { HashMap typeA = new HashMap<>(); @@ -439,6 +934,8 @@ class PdaServiceImplTest { material.put("materialCode", "MAT001"); material.put("materialName", "物料1"); material.put("lotNo", "LOT1"); + material.put("stockinId", "RKD001"); + material.put("inType", "1"); material.put("stockQuantity", "5"); material.put("quantity", "5"); when(pdaMapper.selectInventoryTrayMaterials(any(HashMap.class))).thenReturn(List.of(material)); @@ -515,6 +1012,7 @@ class PdaServiceImplTest { assertThat(inboundDetail.getJobId()).isEqualTo("PR001"); assertThat(inboundDetail.getInOrOutId()).isEqualTo("PC001"); assertThat(inboundDetail.getInOrOutDId()).isEqualTo("PDD001-1"); + assertThat(inboundDetail.getStockinId()).isEqualTo("RKD001"); assertThat(inboundDetail.getStockinDetailId()).isEqualTo("SD001"); assertThat(inboundDetail.getInQuantity()).isEqualByComparingTo("5"); } @@ -528,8 +1026,8 @@ class PdaServiceImplTest { point.setPointStatus("0"); point.setTrayType("A"); point.setRoadwayId("R01"); - when(basStationPointService.selectBasStationPointByPointId("IN01")).thenReturn(point); - when(basStationPointService.occupyBasStationPoint("IN01")).thenReturn(1); + lenient().when(basStationPointService.selectBasStationPointByPointId("IN01")).thenReturn(point); + lenient().when(basStationPointService.occupyBasStationPoint("IN01")).thenReturn(1); HashMap trayRule = new HashMap<>(); trayRule.put("allowed_cargo_type", "A"); @@ -539,6 +1037,25 @@ class PdaServiceImplTest { when(pdaMapper.selectAvailableInboundRoadway(any(HashMap.class))).thenReturn(roadway); } + private void stubWarehouse03InboundStation() { + BasStationPoint point = new BasStationPoint(); + point.setPointId("IN03"); + point.setWarehouseId("03"); + point.setPointType("1"); + point.setStatus("0"); + point.setPointStatus("0"); + point.setTrayType("B"); + point.setRoadwayId("1"); + when(basStationPointService.selectBasStationPointByPointId("IN03")).thenReturn(point); + when(basStationPointService.occupyBasStationPoint("IN03")).thenReturn(1); + + HashMap trayRule = new HashMap<>(); + trayRule.put("allowed_cargo_type", "1"); + when(pdaMapper.selectMatchedTrayRule(any(HashMap.class))).thenReturn(trayRule); + when(pdaMapper.selectAvailableInboundRoadway(any(HashMap.class))) + .thenReturn(new HashMap<>(Map.of("roadway_id", "1"))); + } + private HashMap emptyTrayStock(String detailId, String stockId, String trayCode, String cargoSpaceId, String cargoSpaceName) { HashMap stock = new HashMap<>(); stock.put("stockDetailId", detailId); @@ -562,8 +1079,8 @@ class PdaServiceImplTest { point.setPointStatus("0"); point.setTrayType("A"); point.setRoadwayId("R01"); - when(basStationPointService.selectBasStationPointByPointId("IN01")).thenReturn(point); - when(basStationPointService.occupyBasStationPoint("IN01")).thenReturn(1); + lenient().when(basStationPointService.selectBasStationPointByPointId("IN01")).thenReturn(point); + lenient().when(basStationPointService.occupyBasStationPoint("IN01")).thenReturn(1); HashMap trayRule = new HashMap<>(); trayRule.put("allowed_cargo_type", "A"); diff --git a/ruoyi-ui/src/api/kpi/kpi.js b/ruoyi-ui/src/api/kpi/kpi.js index 4757f49..9473d0b 100644 --- a/ruoyi-ui/src/api/kpi/kpi.js +++ b/ruoyi-ui/src/api/kpi/kpi.js @@ -59,6 +59,17 @@ export function getChartData(query) { }) } +/** + * 密集库货位平面图数据 + */ +export function getDenseChartData(query) { + return request({ + url: '/kpi/denseCargoSpacePlan', + method: 'get', + params: query + }) +} + /** * 货位平面图-货位详情 */ diff --git a/ruoyi-ui/src/views/bas/cargospace/index.vue b/ruoyi-ui/src/views/bas/cargospace/index.vue index c825b25..b1fd60b 100644 --- a/ruoyi-ui/src/views/bas/cargospace/index.vue +++ b/ruoyi-ui/src/views/bas/cargospace/index.vue @@ -1,6 +1,14 @@