22 KiB
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 增加:
@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:
mvn -pl ruoyi-admin -am "-Dtest=ExternalApiControllerWcsCodeTest" -Dsurefire.failIfNoSpecifiedTests=false test
Expected: 新测试因响应对象仍是原对象且阶段为 1 而失败。
- Step 3: 编写最小实现
在控制器导入 org.springframework.beans.BeanUtils,并把主动请求作业成功分支改为:
MydoJob claimed = mydoJobService.claimDenseInboundJob(
request.getTrayCode(), request.getEntranceId(), request.getHeight());
return wcsClaimSuccess(claimed);
新增专用帮助方法,不改通用 wcsSuccess:
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<Integer> orderedLayers, BigDecimal height)。 -
Produces:
lockInboundCargoSpace(..., String roadwayId, String entranceId, BigDecimal height)。 -
Consumes:
MydoJob.entranceId由 WCS 请求作业时保存。 -
Step 1: 写入口参数和 SQL 排序失败测试
在 PdaServiceImplTest 的选层与锁位测试中捕获传给 PdaMapper 的 Map,并增加:
assertThat(captor.getValue()).containsEntry("v_entrance_id", "03-AU-01");
调用签名使用:
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 仓分支分别断言包含:
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:
mvn -pl ruoyi-admin -am "-Dtest=PdaServiceImplTest,PdaMapperSqlTest,BasCargoSpaceMapperSqlTest,ExternalApiControllerWcsCodeTest" -Dsurefire.failIfNoSpecifiedTests=false test
Expected: 编译或断言失败,因为接口没有入口参数且 SQL 没有入口区域优先级。
- Step 3: 沿调用链传入口号
将 PdaService 和实现签名改为:
Map<String, Object> planInboundLayer(String trayCode, String warehouseId, String jobId,
String entranceId, List<Integer> orderedLayers, BigDecimal height);
Map<String, Object> lockInboundCargoSpace(String trayCode, String warehouseId, String roadwayId,
String entranceId, BigDecimal height);
在 PdaServiceImpl 两个参数 Map 中写入:
params.put("v_entrance_id", entranceId);
lockMap.put("v_entrance_id", entranceId);
控制器调用传 job.getEntranceId();同步更新所有测试和既有调用点。
- Step 4: 在三条 SQL 路径加入软优先排序
在候选 CTE 中加入:
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 的选层查询和锁位更新选择中,把排序开头统一为:
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:
selectInventoryTrayMaterialsMap 的stockinId、inType。 -
Produces: 盘点回库
MydoJobDetail.stockinId。 -
Produces: 新库存
bas_stock.stockin_id、bas_stock.in_type。 -
Step 1: 写盘点追溯失败测试
PdaMapperSqlTest 断言盘点物料查询包含:
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 并断言:
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:
mvn -pl ruoyi-admin -am "-Dtest=PdaServiceImplTest,PdaMapperSqlTest,InventoryInboundTraceMigrationSqlTest" -Dsurefire.failIfNoSpecifiedTests=false test
Expected: 查询字段、回库明细赋值和迁移文件缺失导致失败。
- Step 3: 在盘点确认阶段保存原追溯
把查询改为:
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
创建回库明细时增加:
returnDetail.setStockinId(mapString(material, "stockinId"));
- Step 4: 修正完成入库过程的类型恢复
在两个基线脚本的 proc_complete_in_job 中,把只查当前主单的类型查询替换为:
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
新迁移中的回填采用:
;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 增加:
@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<BasStockDetail> 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:
mvn -pl ruoyi-admin -am "-Dtest=BasStockServiceImplTest" -Dsurefire.failIfNoSpecifiedTests=false test
node scripts/verify-wcs-inventory-outbound-fixes.js
Expected: 服务仍接受篡改值,前端/Mapper 静态断言失败。
- Step 3: 实现后端不可变保护
在更新服务调用 Mapper 前增加:
if (oldDetail != null) {
basStockDetail.setJobDetailId(oldDetail.getJobDetailId());
basStockDetail.setStockinDetailId(oldDetail.getStockinDetailId());
}
从 updateBasStockDetail SQL 删除:
<if test="jobDetailId != null">job_detail_id = #{jobDetailId},</if>
<if test="stockinDetailId != null">stockin_detail_id = #{stockinDetailId},</if>
- Step 4: 实现库存明细界面
子表增加:
<el-table-column label="项目号" align="center" prop="projectNo" width="140" show-overflow-tooltip />
状态表单改为:
<el-switch
v-model="detailForm.productStatus"
active-value="1"
inactive-value="2"
active-text="正常"
inactive-text="异常"
/>
两个追溯输入框增加 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 并断言:
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 调用都同时包含:
stockStatus: '1',
productStatus: '1'
- Step 2: 运行测试并确认失败
Run:
mvn -pl ruoyi-admin -am "-Dtest=OutStockoutMapperSqlTest" -Dsurefire.failIfNoSpecifiedTests=false test
node scripts/verify-wcs-inventory-outbound-fixes.js
Expected: 自动/手动过程或前端查询缺少物料状态条件导致失败。
- Step 3: 实现前端候选过滤
出库创建页的 stockQuery 初始化增加:
stockStatus: '1',
productStatus: '1'
重置搜索时不清空这两个固定值。手动分配调用增加 productStatus: '1',保留现有 stockStatus: '1'。
- Step 4: 实现 SQL 双状态过滤
在手动过程和自动过程的候选 WHERE 中、可用数量条件之前增加:
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: 运行专项后端测试
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静态需求验证
node scripts/verify-wcs-inventory-outbound-fixes.js
Expected: 六项检查全部输出 PASS,进程退出码 0。
- Step 3: 运行后端完整测试和打包
mvn test
mvn clean package -DskipTests
Expected: 两条命令均 BUILD SUCCESS。
- Step 4: 运行管理端生产构建
cd ruoyi-ui
npm run build:prod
Expected: 退出码 0,无编译错误。
- Step 5: 执行工作区质量检查
git diff --check
git status --short
Expected: 无新增空白错误;状态列表只包含预期改动和实施前已有改动。
- Step 6: 逐项完成审计
确认以下证据全部存在:请求作业专用响应测试;四入口两区域 SQL;盘点追溯查询、过程和回填迁移;项目号列;只读追溯字段与开关;UI 与两个分配过程双正常过滤。任何一项证据缺失都不得报告完成。
- Step 7: 提交策略检查点
由于目标文件包含实施前未提交改动,本计划默认不暂存、不提交实现文件。最终交付列出本次修改文件和验证结果,由用户决定是否整体提交。