fix: 保存前重校验出库库存

This commit is contained in:
zhouz
2026-07-22 16:02:43 +08:00
parent a65f96dab6
commit 4e276c5b05
12 changed files with 179 additions and 5 deletions

View File

@@ -14,6 +14,8 @@ public interface ErrorCodeConstants {
ErrorCode IOSTOR_INV_CODE_GENERATE_FAILED = new ErrorCode(6_000_4, "单据号生成失败");
ErrorCode IOSTOR_INV_INVENTORY_INVALID = new ErrorCode(6_000_5, "出库库存已变化,请刷新后重新选择完整箱库存");
ErrorCode IOSTORINV_DTL_NOT_EXISTS = new ErrorCode(5, "出入库单明细不存在");
ErrorCode IOSTORINV_DIS_NOT_EXISTS = new ErrorCode(6, "出入库单分配不存在");

View File

@@ -39,6 +39,12 @@ public class IostorInvCreateReqVO {
@Data
public static class Detail {
@Schema(description = "组盘记录标识(仅库存选取方式提交)")
private Long groupId;
@Schema(description = "箱号(仅库存选取方式提交)")
private String vehicleCode;
@Schema(description = "物料编码", requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty(message = "物料编码不能为空")
private String materialCode;

View File

@@ -43,6 +43,10 @@ public interface IostorinvDtlMapper extends BaseMapperX<IostorinvDtlDO> {
List<AvailableInventoryRespVO> selectAvailableInventoryByVehicleCodes(
@Param("storId") String storId, @Param("vehicleCodes") Collection<String> vehicleCodes);
/** 保存前重查并锁定指定仓库、箱号下的全部可用组盘行。 */
List<AvailableInventoryRespVO> selectAvailableInventoryByVehicleCodesForUpdate(
@Param("storId") String storId, @Param("vehicleCodes") Collection<String> vehicleCodes);
default PageResult<IostorinvDtlDO> selectPage(IostorinvDtlPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<IostorinvDtlDO>()
.eqIfPresent(IostorinvDtlDO::getIostorinvId, reqVO.getIostorinvId())

View File

@@ -54,6 +54,8 @@ public class IostorInvServiceImpl implements IostorInvService {
@Override
@Transactional(rollbackFor = Exception.class)
public String createOutbound(IostorInvCreateReqVO reqVO) {
validateAndLockInventory(reqVO);
String ruleCode = "IO_CODE";
CodeGenerateReqDTO codeGenerateReqDTO = new CodeGenerateReqDTO();
codeGenerateReqDTO.setRuleCode(ruleCode);
@@ -110,6 +112,54 @@ public class IostorInvServiceImpl implements IostorInvService {
return iostorInv.getIostorinvId();
}
private void validateAndLockInventory(IostorInvCreateReqVO reqVO) {
List<IostorInvCreateReqVO.Detail> inventoryDetails = new ArrayList<>();
Set<Long> requestedGroupIds = new HashSet<>();
Set<String> vehicleCodes = new LinkedHashSet<>();
for (IostorInvCreateReqVO.Detail detail : reqVO.getDetails()) {
boolean hasGroupId = detail.getGroupId() != null;
boolean hasVehicleCode = StrUtil.isNotBlank(detail.getVehicleCode());
if (hasGroupId != hasVehicleCode) {
throw exception(IOSTOR_INV_INVENTORY_INVALID);
}
if (!hasGroupId) {
continue;
}
if (!requestedGroupIds.add(detail.getGroupId())) {
throw exception(IOSTOR_INV_INVENTORY_INVALID);
}
inventoryDetails.add(detail);
vehicleCodes.add(detail.getVehicleCode());
}
if (inventoryDetails.isEmpty()) {
return;
}
List<AvailableInventoryRespVO> lockedRows =
iostorinvDtlMapper.selectAvailableInventoryByVehicleCodesForUpdate(reqVO.getStorId(), vehicleCodes);
Map<Long, AvailableInventoryRespVO> lockedByGroupId = new HashMap<>();
for (AvailableInventoryRespVO row : lockedRows) {
if (lockedByGroupId.put(row.getGroupId(), row) != null) {
throw exception(IOSTOR_INV_INVENTORY_INVALID);
}
}
if (!lockedByGroupId.keySet().equals(requestedGroupIds)) {
throw exception(IOSTOR_INV_INVENTORY_INVALID);
}
for (IostorInvCreateReqVO.Detail detail : inventoryDetails) {
AvailableInventoryRespVO row = lockedByGroupId.get(detail.getGroupId());
if (!Objects.equals(detail.getVehicleCode(), row.getVehicleCode())
|| !Objects.equals(detail.getMaterialId(), row.getMaterialId())
|| !Objects.equals(detail.getMaterialCode(), row.getMaterialCode())
|| !Objects.equals(detail.getPcsn(), row.getPcsn())
|| detail.getPlanQty() == null || detail.getPlanQty().signum() <= 0
|| detail.getPlanQty().compareTo(row.getAvailableQty()) > 0) {
throw exception(IOSTOR_INV_INVENTORY_INVALID);
}
}
// 建单仅验证库存快照;库存占用和冻结数量由后续分配流程处理。
}
@Override
public PageResult<AvailableInventoryRespVO> getAvailableInventoryPage(AvailableInventoryPageReqVO reqVO) {
return iostorinvDtlMapper.selectAvailableInventoryPage(reqVO);

View File

@@ -9,7 +9,7 @@
gp.material_id AS material_id,
gp.material_code AS material_code,
mb.material_name AS material_name,
gp.qty - gp.frozen_qty AS available_qty,
gp.qty - COALESCE(gp.frozen_qty, 0) AS available_qty,
gp.qty_unit_id AS qty_unit_id,
gp.qty_unit_name AS qty_unit_name,
gp.ext_code AS ext_code,
@@ -33,7 +33,7 @@
LEFT JOIN base_materialbase mb ON mb.material_id = gp.material_id AND mb.deleted = 0
WHERE sa.stor_id = #{reqVO.storId}
AND gp.status = '可用'
AND gp.qty - gp.frozen_qty &gt; 0
AND gp.qty - COALESCE(gp.frozen_qty, 0) &gt; 0
AND gp.deleted = 0
<if test="reqVO.materialCode != null and reqVO.materialCode != ''">
AND gp.material_code LIKE CONCAT('%', #{reqVO.materialCode}, '%')
@@ -60,7 +60,7 @@
LEFT JOIN base_materialbase mb ON mb.material_id = gp.material_id AND mb.deleted = 0
WHERE sa.stor_id = #{storId}
AND gp.status = '可用'
AND gp.qty - gp.frozen_qty &gt; 0
AND gp.qty - COALESCE(gp.frozen_qty, 0) &gt; 0
AND gp.deleted = 0
AND gp.vehicle_code IN
<foreach collection="vehicleCodes" item="vehicleCode" open="(" separator="," close=")">
@@ -69,4 +69,27 @@
ORDER BY gp.vehicle_code, gp.group_id
</select>
<select id="selectAvailableInventoryByVehicleCodesForUpdate"
resultType="cn.code.nl.module.wms.controller.admin.iostorinv.vo.AvailableInventoryRespVO">
SELECT <include refid="availableInventoryColumns"/>
FROM wms_group_plate gp
INNER JOIN (
SELECT stor_id, storagevehicle_code, MAX(stor_code) AS stor_code, MAX(stor_name) AS stor_name
FROM wms_structattr
WHERE deleted = 0
GROUP BY stor_id, storagevehicle_code
) sa ON sa.storagevehicle_code = gp.vehicle_code
LEFT JOIN base_materialbase mb ON mb.material_id = gp.material_id AND mb.deleted = 0
WHERE sa.stor_id = #{storId}
AND gp.status = '可用'
AND gp.qty - COALESCE(gp.frozen_qty, 0) &gt; 0
AND gp.deleted = 0
AND gp.vehicle_code IN
<foreach collection="vehicleCodes" item="vehicleCode" open="(" separator="," close=")">
#{vehicleCode}
</foreach>
ORDER BY gp.vehicle_code, gp.group_id
FOR UPDATE
</select>
</mapper>

View File

@@ -58,6 +58,11 @@ class IostorinvDtlMapperTest {
assertTrue(expanded.stream().allMatch(item -> "STOR-001".equals(item.getStorId())));
assertEquals(2, expanded.stream().map(AvailableInventoryRespVO::getPcsn).distinct().count());
assertTrue(expanded.stream().allMatch(item -> item.getAvailableQty().signum() > 0));
assertEquals(0, new java.math.BigDecimal("5").compareTo(expanded.get(1).getAvailableQty()));
List<AvailableInventoryRespVO> locked = mapper.selectAvailableInventoryByVehicleCodesForUpdate(
"STOR-001", List.of("BOX-001"));
assertEquals(List.of(1L, 2L), locked.stream().map(AvailableInventoryRespVO::getGroupId).toList());
AvailableInventoryPageReqVO warehousePageReqVO = new AvailableInventoryPageReqVO();
warehousePageReqVO.setStorId("STOR-001");

View File

@@ -156,6 +156,57 @@ class IostorInvServiceLocalSpringTest {
assertEquals(0, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM wms_iostorinvdtl", Integer.class));
}
@Test
void shouldRejectMissingChildOfSameBoxBeforeGeneratingCode() {
jdbcTemplate.update("INSERT INTO wms_group_plate VALUES (104,'BOX-01','可用','MID-01','MAT-01','PCSN-EXTRA',1,0,'KG','千克',NULL,NULL,NULL,FALSE)");
assertInventoryRejected(buildRequest());
}
@Test
void shouldRejectCrossWarehouseGroup() {
IostorInvCreateReqVO request = buildRequest();
IostorInvCreateReqVO.Detail detail = request.getDetails().get(0);
detail.setGroupId(103L);
detail.setVehicleCode("BOX-X");
detail.setPcsn("PCSN-X");
detail.setPlanQty(new BigDecimal("5"));
assertInventoryRejected(request);
}
@Test
void shouldRejectQuantityAboveAvailable() {
IostorInvCreateReqVO request = buildRequest();
request.getDetails().get(1).setPlanQty(new BigDecimal("7.251"));
assertInventoryRejected(request);
}
@Test
void shouldRejectDuplicateGroup() {
IostorInvCreateReqVO request = buildRequest();
request.getDetails().get(1).setGroupId(101L);
request.getDetails().get(1).setVehicleCode("BOX-01");
assertInventoryRejected(request);
}
@Test
void shouldRejectInventoryChangedToUnavailable() {
jdbcTemplate.update("UPDATE wms_group_plate SET status = '不可用' WHERE group_id = 101");
assertInventoryRejected(buildRequest());
}
@Test
void shouldRejectInventoryChangedToFullyFrozen() {
jdbcTemplate.update("UPDATE wms_group_plate SET frozen_qty = qty WHERE group_id = 101");
assertInventoryRejected(buildRequest());
}
private void assertInventoryRejected(IostorInvCreateReqVO request) {
ServiceException ex = assertThrows(ServiceException.class, () -> iostorInvService.createOutbound(request));
assertEquals("出库库存已变化,请刷新后重新选择完整箱库存", ex.getMessage());
assertEquals(0, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM wms_iostorinv", Integer.class));
assertEquals(0, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM wms_iostorinvdtl", Integer.class));
}
private IostorInvCreateReqVO buildRequest() {
IostorInvCreateReqVO request = new IostorInvCreateReqVO();
request.setBillType("销售出库");
@@ -179,6 +230,8 @@ class IostorInvServiceLocalSpringTest {
detail.setSourceBillType("ORDER");
detail.setSourceBilldtlId("SRC-DTL-" + suffix);
detail.setRemark(remark);
detail.setGroupId(Long.valueOf("1" + suffix));
detail.setVehicleCode("BOX-" + suffix);
return detail;
}
@@ -205,6 +258,8 @@ class IostorInvServiceLocalSpringTest {
SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(dataSource);
factory.setMapperLocations(new org.springframework.core.io.ClassPathResource(
"mapper/iostorinvdtl/IostorinvDtlMapper.xml"));
MybatisConfiguration configuration = new MybatisConfiguration();
configuration.setMapUnderscoreToCamelCase(true);
factory.setConfiguration(configuration);

View File

@@ -55,3 +55,5 @@ VALUES (1, 'BOX-001', '可用', '1001', 'MAT-001', 'PCSN-001', 10, 2, 'UNIT-1',
(4, 'BOX-003', '可用', '1001', 'MAT-001', 'PCSN-004', 10, 10, 'UNIT-1', '', 'EXT-4', 'TYPE-1', 'DTL-4', FALSE),
(5, 'BOX-004', '可用', '1001', 'MAT-001', 'PCSN-005', 10, 0, 'UNIT-1', '', 'EXT-5', 'TYPE-1', 'DTL-5', TRUE),
(6, 'BOX-005', '可用', '1001', 'MAT-001', 'PCSN-006', 10, 0, 'UNIT-1', '', 'EXT-6', 'TYPE-1', 'DTL-6', FALSE);
UPDATE wms_group_plate SET frozen_qty = NULL WHERE group_id = 2;

View File

@@ -1,5 +1,8 @@
DROP TABLE IF EXISTS wms_iostorinvdtl;
DROP TABLE IF EXISTS wms_iostorinv;
DROP TABLE IF EXISTS wms_group_plate;
DROP TABLE IF EXISTS wms_structattr;
DROP TABLE IF EXISTS base_materialbase;
CREATE TABLE wms_iostorinv (
iostorinv_id VARCHAR(64) PRIMARY KEY,
bill_code VARCHAR(64), io_type VARCHAR(32), bill_type VARCHAR(64), biz_date TIMESTAMP,
@@ -22,3 +25,13 @@ CREATE TABLE wms_iostorinvdtl (
material_id VARCHAR(64), create_time TIMESTAMP, update_time TIMESTAMP,
creator VARCHAR(64), updater VARCHAR(64), deleted BOOLEAN DEFAULT FALSE
);
CREATE TABLE base_materialbase (material_id VARCHAR(64) PRIMARY KEY, material_name VARCHAR(64), deleted BOOLEAN NOT NULL);
CREATE TABLE wms_structattr (struct_id VARCHAR(64) PRIMARY KEY, storagevehicle_code VARCHAR(64), stor_id VARCHAR(64), stor_code VARCHAR(64), stor_name VARCHAR(64), deleted BOOLEAN NOT NULL);
CREATE TABLE wms_group_plate (group_id BIGINT PRIMARY KEY, vehicle_code VARCHAR(64), status VARCHAR(16), material_id VARCHAR(64), material_code VARCHAR(64), pcsn VARCHAR(64), qty DECIMAL(18,3), frozen_qty DECIMAL(18,3), qty_unit_id VARCHAR(64), qty_unit_name VARCHAR(64), ext_code VARCHAR(64), ext_type VARCHAR(64), ext_dtl_code VARCHAR(64), deleted BOOLEAN NOT NULL);
INSERT INTO base_materialbase VALUES ('MID-01','物料一',FALSE), ('MID-02','物料二',FALSE);
INSERT INTO wms_structattr VALUES ('S1','BOX-01','STOR-01','S01','一号仓',FALSE), ('S2','BOX-02','STOR-01','S01','一号仓',FALSE), ('S3','BOX-X','STOR-02','S02','二号仓',FALSE);
INSERT INTO wms_group_plate VALUES
(101,'BOX-01','可用','MID-01','MAT-01','PCSN-01',12.500,NULL,'KG','千克',NULL,NULL,NULL,FALSE),
(102,'BOX-02','可用','MID-02','MAT-02','PCSN-02',8.000,0.750,'KG','千克',NULL,NULL,NULL,FALSE),
(103,'BOX-X','可用','MID-01','MAT-01','PCSN-X',5.000,0,'KG','千克',NULL,NULL,NULL,FALSE);