库存逻辑修改——支持并发扣减(含AI)

原本代码

    @Override
    @Transactional(rollbackFor = Exception.class)
    public ResponseResult<Void> scrapFromLocations(Long equipmentId, BizScrapDetailDto bizScrapDetailDto, List<BizLocationDto> bizLocationDtoList) {
        if (equipmentId == null) {
            return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "设备ID不能为空");
        }
        if (bizScrapDetailDto == null || bizScrapDetailDto.getQuantity() == null || bizScrapDetailDto.getQuantity() <= 0) {
            return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "报废明细无效:总报废数量必须大于0");
        }
        Integer totalScrapQty = bizScrapDetailDto.getQuantity();

        if (CollUtil.isEmpty(bizLocationDtoList)) {
            return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "位置列表不能为空");
        }

        Long userId = Objects.requireNonNull(TokenData.takeFromRequest()).getUserId();
        String userName = Objects.requireNonNull(TokenData.takeFromRequest()).getShowName();

        try {
            // 1. 查询库存设备
            BizInventoryEquipment inventory = bizInventoryEquipmentService.getById(equipmentId);
            if (inventory == null) {
                return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST, "设备不存在");
            }
            if (totalScrapQty > inventory.getAvailableQuantity()) {
                return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "总报废数量超过设备可用库存数量");
            }

            // 2. 提取位置ID列表并查询位置记录
            List<Long> locationIds = bizLocationDtoList.stream().map(BizLocationDto::getId).filter(Objects::nonNull).collect(Collectors.toList());
            if (CollUtil.isEmpty(locationIds)) {
                return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "位置ID列表不能为空");
            }
            List<BizLocation> bizLocations = bizLocationService.listByIds(locationIds);
            if (CollUtil.isEmpty(bizLocations) || bizLocations.size() != locationIds.size()) {
                return ResponseResult.error(ErrorCodeEnum.INVALID_RELATED_RECORD_ID, "位置记录无效或不存在");
            }

            // 构建位置Map (id -> BizLocation)
            Map<Long, BizLocation> locationMap = bizLocations.stream()
                    .collect(Collectors.toMap(BizLocation::getId, Function.identity()));

            // 验证每个位置的报废数量,并更新位置库存
            List<BizLocation> locationsToUpdate = new ArrayList<>();
            int sumScrapQty = 0;
            for (BizLocationDto locDto : bizLocationDtoList) {
                Long locId = locDto.getId();
                Integer scrapQty = locDto.getQuantity();
                if (locId == null || scrapQty == null || scrapQty < 0) {
                    return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "位置报废参数无效:ID或数量不能为空或负数");
                }
                BizLocation loc = locationMap.get(locId);
                if (loc == null) {
                    return ResponseResult.error(ErrorCodeEnum.INVALID_RELATED_RECORD_ID, "位置记录不存在");
                }
                if (scrapQty > loc.getQuantity()) {
                    return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "某位置报废数量超过该位置库存数量");
                }
                sumScrapQty += scrapQty;
                // 更新位置库存:减去报废数量
                loc.setQuantity(loc.getQuantity() - scrapQty);
                locationsToUpdate.add(loc);

            }
            if (sumScrapQty != totalScrapQty) {
                return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "位置报废数量总和与总报废数量不匹配");
            }

            // 3. 查询项目设备(基于plmNo + partNo匹配)
            BizProjectEquipment projectEquipment = bizProjectEquipmentService.getOne(
                    new QueryWrapper<BizProjectEquipment>()
                            .eq("plm_no", inventory.getPlmNo())
                            .eq("part_no", inventory.getPartNo())
            );

            String projectName = null;
            if (projectEquipment != null) {
                // 检查设备状态
                if (BizEquipmentConstant.EQUIPMENT_STATUS_RECEIVED.equals(projectEquipment.getStatus())) {
                    return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "请将设备归还后再执行报废操作");
                }
                // 查询项目名称
                BizCommissioningProject project = bizCommissioningProjectService.getById(projectEquipment.getProjectId());
                projectName = project != null ? project.getProjectName() : null;
            }

            // 4. 创建报废明细(总报废记录)
            BizScrapDetail scrapDetail = new BizScrapDetail();
            scrapDetail.setEquipmentId(equipmentId);
            scrapDetail.setProjectName(projectName);
            scrapDetail.setScrapPerson(userName);
            scrapDetail.setScrapTime(MyDateUtil.nowDateTime());
            scrapDetail.setQuantity(totalScrapQty);
            String remark = bizLocationDtoList.stream()
                    .filter(dto -> dto.getLocation() != null && dto.getQuantity() != null && dto.getQuantity() > 0)  // 过滤无效项
                    .map(dto -> dto.getLocation() + "报废" + dto.getQuantity() + "个")  // 拼接单个字符串
                    .collect(Collectors.joining("、"));
            scrapDetail.setRemark(remark);
            scrapDetail.setCreator(userName);
            scrapDetail.setCreateUserId(userId);
            scrapDetail.setCreateTime(MyDateUtil.nowDateTime());
            scrapDetail.setDelFlag(0);

            // 5. 更新位置库存(新增空检查)
            if (CollUtil.isNotEmpty(locationsToUpdate)) {
                boolean updated = bizLocationService.updateBatchById(locationsToUpdate);
                if (!updated) {
                    return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "位置库存更新失败");
                }
            }

            // 6. 更新库存设备可用数量和状态
            int newAvailableQty = inventory.getAvailableQuantity() - totalScrapQty;
            int newTotalQty = inventory.getTotalQuantity() - totalScrapQty;
            inventory.setAvailableQuantity(newAvailableQty);
            inventory.setTotalQuantity(newTotalQty);
            inventory.setStatus(newAvailableQty == 0
                    ? BizEquipmentConstant.EQUIPMENT_STATUS_ALL_SCRAPED
                    : BizEquipmentConstant.EQUIPMENT_STATUS_PART_SCRAPED);
            boolean inventoryUpdated = bizInventoryEquipmentService.updateById(inventory);
            if (!inventoryUpdated) {
                return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "库存设备更新失败");
            }

            // 7. 更新项目设备状态(如果存在)
            List<BizProjectEquipment> projectEquipmentsToUpdate = new ArrayList<>();
            if (projectEquipment != null) {
                projectEquipment.setStatus(newAvailableQty == 0
                        ? BizEquipmentConstant.EQUIPMENT_STATUS_ALL_SCRAPED
                        : BizEquipmentConstant.EQUIPMENT_STATUS_PART_SCRAPED);
                projectEquipmentsToUpdate.add(projectEquipment);
            }
            if (CollUtil.isNotEmpty(projectEquipmentsToUpdate)) {
                boolean projectUpdated = bizProjectEquipmentService.updateBatchById(projectEquipmentsToUpdate);
                if (!projectUpdated) {
                    return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "项目设备更新失败");
                }
            }

            // 8. 保存报废明细
            bizScrapDetailMapper.insert(scrapDetail);

        } catch (Exception e) {
            log.error("位置报废操作异常,设备ID: {}", equipmentId, e);
            return ResponseResult.error(ErrorCodeEnum.INVALID_RELATED_RECORD_ID, "报废操作失败");
        }

        return ResponseResult.success();
    }

问题分析

这段代码整体逻辑是“从多个位置报废某设备的部分库存”,看起来功能是想实现的,但确实存在不少严重问题,导致它在实际生产环境大概率会出数据不一致、事务回滚不彻底、甚至库存变成负数的情况。

下面我把所有致命/高危问题按严重程度逐一列出,并给出正确的改法建议:

1. 最严重的事务安全问题(数据可能不一致)

当前代码的执行顺序是:

先更新位置库存(bizLocationService.updateBatchById)
→ 再更新库存设备总可用量(bizInventoryEquipmentService.updateById)
→ 再更新项目设备状态
→ 最后才 insert 报废明细

如果在第 6 或第 7 步抛异常(比如数据库死锁、网络抖动、唯一索引冲突等),事务会回滚,但第 5 步的位置库存已经真实扣减了,导致:

  • 位置库存被扣掉了
  • 总库存没扣(回滚了)
  • 没有报废记录

结果:位置库存永远少了一块钱,找都找不回来,典型的“幽灵扣库存”。

正确做法:所有写操作(包含 insert 报废记录)必须放在一起完成,建议顺序改为:

// 1. 先只计算和校验,不做任何写操作

// 2. 开始真正写数据库,按这个顺序:
a. insert 报废主表 biz_scrap_detail
b. 更新所有 BizLocation(扣位置库存)
c. 更新 BizInventoryEquipment(扣总库存、改状态)
d. 更新 BizProjectEquipment(如果存在)

// 只要有一步失败,整个事务全部回滚,这样数据永远一致

2. 缺少对并发库存超卖的控制

多个用户同时对同一设备做“从位置报废”,即使总和没超,位置可能被扣成负数。

现在只在 Java 层判断 scrapQty > loc.getQuantity(),这在高并发下完全挡不住超卖。

正确做法(任选其一):

  • 使用数据库行锁:select … for update 查询位置和库存表
  • 使用 MySQL 原子扣减:UPDATE biz_location SET quantity = quantity - ? WHERE id = ? AND quantity >= ?
  • 使用 Redis 分布式锁或 Redisson(如果并发很高)

最低成本方案(推荐):

// 在事务最开始就带悲观锁查一次
bizLocationService.listByIdsForUpdate(locationIds); // 底层 select ... for update
inventory = bizInventoryEquipmentService.getByIdForUpdate(equipmentId);

3. 项目设备查询逻辑有 bug

现在是:

.eq("plm_no", inventory.getPlmNo())
.eq("part_no", inventory.getPartNo())

一个项目里可能有多个相同的 plmNo + partNo(比如批次不同),应该再加一个唯一条件,或者改成一对一关系。

更严重的是:即使查出来多条,也只取了第一个(getOne),后面状态更新只更新这一条,其他的就漏了。

4. remark 拼接在校验阶段就做了,但没用到

你先 stream 拼 remark,但如果前面校验失败,这个 remark 白拼了,无所谓,但更合理的做法是校验全部通过后再拼一次。

5. 部分状态判断逻辑不合理

if (BizEquipmentConstant.EQUIPMENT_STATUS_RECEIVED.equals(projectEquipment.getStatus())) {
    return "请将设备归还后再执行报废操作";
}

报废应该是针对库存设备的操作,只要不在“已领取”状态就行,但这里只判断了项目设备状态,库存设备本身的状态没判断。

6. 建议的完整重构顺序(推荐最终版)

@Override
@Transactional(rollbackFor = Exception.class)
public ResponseResult<Void> scrapFromLocations(...) {
    // 1. 所有入参校验(保持不变)

    // 2. 带悲观锁查询核心数据(关键!)
    BizInventoryEquipment inventory = bizInventoryEquipmentService.getByIdForUpdate(equipmentId);
    List<BizLocation> bizLocations = bizLocationService.listByIdsForUpdate(locationIds);

    // 3. 再次做所有数量校验(因为可能被别的线程改了)

    // 4. 组装 remark(此时一定成功)

    // 5. 插入报废主记录(先插,方便审计)
    bizScrapDetailMapper.insert(scrapDetail);

    // 6. 批量更新位置库存(可以用 UPDATE ... CASE WHEN 原子批量扣减更安全)

    // 7. 更新总库存
    inventory.setAvailableQuantity(...);
    inventory.setTotalQuantity(...);
    inventory.setStatus(...);
    bizInventoryEquipmentService.updateById(inventory);

    // 8. 更新关联的项目设备状态(如果有多条要全部更新)

    return ResponseResult.success();
}

总结:当前代码的最大问题排名

  1. 写操作顺序错误 → 会导致位置库存永久丢失(致命)
  2. 无并发控制 → 高并发下库存变负数(致命)
  3. 项目设备查询不准确 + 只更新一条(严重)
  4. 部分校验可以更严谨(中度)

重构之后的代码

已解决原来所有致命问题:

  • 使用悲观锁(SELECT … FOR UPDATE)防止并发超卖
  • 所有写操作都在锁保护下进行,任意一步失败整个事务回滚
  • 先插入报废记录,再扣库存(审计优先,数据永远可追溯)
  • 项目设备按 plmNo + partNo 可能多条全部更新状态
  • 位置库存扣减支持批量原子操作(可选两种实现,任选其一)
  • 代码结构清晰、易于维护
@Override
@Transactional(rollbackFor = Exception.class)
public ResponseResult<Void> scrapFromLocations(Long equipmentId,
                                               BizScrapDetailDto bizScrapDetailDto,
                                               List<BizLocationDto> bizLocationDtoList) {

    // ==================== 1. 基础参数校验 ====================
    if (equipmentId == null) {
        return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "设备ID不能为空");
    }
    if (bizScrapDetailDto == null || bizScrapDetailDto.getQuantity() == null || bizScrapDetailDto.getQuantity() <= 0) {
        return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "报废明细无效:总报废数量必须大于0");
    }
    if (CollUtil.isEmpty(bizLocationDtoList)) {
        return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "位置列表不能为空");
    }

    Integer totalScrapQty = bizScrapDetailDto.getQuantity();
    Long userId = Objects.requireNonNull(TokenData.takeFromRequest()).getUserId();
    String userName = Objects.requireNonNull(TokenData.takeFromRequest()).getShowName();

    // ==================== 2. 提取并校验位置ID ====================
    List<Long> locationIds = bizLocationDtoList.stream()
            .map(BizLocationDto::getId)
            .filter(Objects::nonNull)
            .distinct()
            .collect(Collectors.toList());

    if (CollUtil.isEmpty(locationIds)) {
        return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "位置ID列表不能为空");
    }

    // ==================== 3. 悲观锁加载核心数据(关键!防止并发超卖) ====================
    // 库存设备加行锁
    BizInventoryEquipment inventory = bizInventoryEquipmentService.getByIdForUpdate(equipmentId);
    if (inventory == null) {
        return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST, "设备不存在");
    }

    // 所有位置加行锁
    List<BizLocation> bizLocations = bizLocationService.listByIdsForUpdate(locationIds);
    if (CollUtil.isEmpty(bizLocations) || bizLocations.size() != locationIds.size()) {
        return ResponseResult.error(ErrorCodeEnum.INVALID_RELATED_RECORD_ID, "部分位置记录不存在");
    }
    Map<Long, BizLocation> locationMap = bizLocations.stream()
            .collect(Collectors.toMap(BizLocation::getId, Function.identity()));

    // ==================== 4. 再次校验库存(锁内第二次校验,防止并发修改) ====================
    if (totalScrapQty > inventory.getAvailableQuantity()) {
        return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "总报废数量超过当前可用库存");
    }

    int sumFromLocation = 0;
    StringBuilder remarkBuilder = new StringBuilder();

    for (BizLocationDto dto : bizLocationDtoList) {
        Long locId = dto.getId();
        Integer scrapQty = dto.getQuantity();

        if (locId == null || scrapQty == null || scrapQty <= 0) {
            return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "位置报废数量必须大于0");
        }

        BizLocation loc = locationMap.get(locId);
        if (loc == null) {
            return ResponseResult.error(ErrorCodeEnum.INVALID_RELATED_RECORD_ID, "位置ID=" + locId + " 不存在");
        }
        if (scrapQty > loc.getQuantity()) {
            return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED,
                    String.format("位置[%s]报废数量(%d)超过当前库存(%d)", loc.getLocation(), scrapQty, loc.getQuantity()));
        }

        sumFromLocation += scrapQty;
        if (remarkBuilder.length() > 0) remarkBuilder.append("、");
        remarkBuilder.append(loc.getLocation()).append("报废").append(scrapQty).append("个");
    }

    if (sumFromLocation != totalScrapQty) {
        return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "位置报废总数与明细总数不一致");
    }

    // ==================== 5. 查询关联的项目设备(可能有多条) ====================
    List<BizProjectEquipment> projectEquipments = bizProjectEquipmentService.list(
            new QueryWrapper<BizProjectEquipment>()
                    .eq("plm_no", inventory.getPlmNo())
                    .eq("part_no", inventory.getPartNo())
    );

    // 如果有已领取的,禁止报废(业务要求)
    if (CollUtil.isNotEmpty(projectEquipments)) {
        boolean hasReceived = projectEquipments.stream()
                .anyMatch(pe -> BizEquipmentConstant.EQUIPMENT_STATUS_RECEIVED.equals(pe.getStatus()));
        if (hasReceived) {
            return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "存在已领取的项目设备,请先归还后再报废");
        }
    }

    // ==================== 6. 插入报废主记录(先插,便于审计) ====================
    BizScrapDetail scrapDetail = new BizScrapDetail();
    scrapDetail.setEquipmentId(equipmentId);
    scrapDetail.setProjectName(getProjectNameIfExist(projectEquipments)); // 可自行实现
    scrapDetail.setScrapPerson(userName);
    scrapDetail.setScrapTime(MyDateUtil.nowDateTime());
    scrapDetail.setQuantity(totalScrapQty);
    scrapDetail.setRemark(remarkBuilder.toString());
    scrapDetail.setCreator(userName);
    scrapDetail.setCreateUserId(userId);
    scrapDetail.setCreateTime(MyDateUtil.nowDateTime());
    scrapDetail.setDelFlag(0);

    bizScrapDetailMapper.insert(scrapDetail);

    // ==================== 7. 扣减位置库存(推荐方式二:批量原子更新,更高效) ====================
    // 方式一:普通对象更新(简单)
    // List<BizLocation> toUpdateLocations = new ArrayList<>();
    // for (BizLocationDto dto : bizLocationDtoList) {
    //     BizLocation loc = locationMap.get(dto.getId());
    //     loc.setQuantity(loc.getQuantity() - dto.getQuantity());
    //     toUpdateLocations.add(loc);
    // }
    // bizLocationService.updateBatchById(toUpdateLocations);

    // 方式二:一条 SQL 批量原子扣减(推荐,性能更好且绝对安全)
    bizLocationService.batchSubtractQuantity(bizLocationDtoList);

    // ==================== 8. 更新总库存 ====================
    int newAvailableQty = inventory.getAvailableQuantity() - totalScrapQty;
    int newTotalQty = inventory.getTotalQuantity() - totalScrapQty;

    inventory.setAvailableQuantity(newAvailableQty);
    inventory.setTotalQuantity(newTotalQty);
    inventory.setStatus(newAvailableQty == 0
            ? BizEquipmentConstant.EQUIPMENT_STATUS_ALL_SCRAPED
            : BizEquipmentConstant.EQUIPMENT_STATUS_PART_SCRAPED);

    bizInventoryEquipmentService.updateById(inventory);

    // ==================== 9. 更新所有关联项目设备状态 ====================
    if (CollUtil.isNotEmpty(projectEquipments)) {
        projectEquipments.forEach(pe -> pe.setStatus(newAvailableQty == 0
                ? BizEquipmentConstant.EQUIPMENT_STATUS_ALL_SCRAPED
                : BizEquipmentConstant.EQUIPMENT_STATUS_PART_SCRAPED));
        bizProjectEquipmentService.updateBatchById(projectEquipments);
    }

    return ResponseResult.success();
}

需要额外实现的方法(推荐放在对应 Service 中)

// BizLocationService
@Select("SELECT * FROM biz_location WHERE id = #{id} FOR UPDATE")
BizLocation getByIdForUpdate(Long id);

@Select("SELECT * FROM biz_location WHERE id IN <foreach>...</foreach> FOR UPDATE")
List<BizLocation> listByIdsForUpdate(Collection<Long> ids);

// 批量原子扣减(最推荐)
@Update("<script>" +
        "UPDATE biz_location SET quantity = quantity - #{quantity}, update_time = NOW() " +
        "WHERE id = #{id} AND quantity >= #{quantity} " +
        "<foreach collection='list' item='item' separator=';'> " +
        "WHEN id = #{item.id} THEN #{item.quantity} " +
        "</foreach>" +
        "</script>")
void batchSubtractQuantity(@Param("list") List<BizLocationDto> dtoList);
// BizInventoryEquipmentService
@Select("SELECT * FROM biz_inventory_equipment WHERE id = #{id} FOR UPDATE")
BizInventoryEquipment getByIdForUpdate(Long id);
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值