Files
FTC25009/docs/superpowers/plans/2026-07-15-wcs-inventory-outbound-fixes.md

22 KiB
Raw Blame History

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: selectInventoryTrayMaterials Map 的 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, '')) &lt;= 1
       AND COUNT(DISTINCT ISNULL(COALESCE(current_in_type, history_in_type, archived_in_type), '')) &lt;= 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: 提交策略检查点

由于目标文件包含实施前未提交改动,本计划默认不暂存、不提交实现文件。最终交付列出本次修改文件和验证结果,由用户决定是否整体提交。