fix: 保存前重校验出库库存
This commit is contained in:
@@ -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, "出入库单分配不存在");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 > 0
|
||||
AND gp.qty - COALESCE(gp.frozen_qty, 0) > 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 > 0
|
||||
AND gp.qty - COALESCE(gp.frozen_qty, 0) > 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) > 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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -37,6 +37,8 @@ export namespace WmsIostorInvApi {
|
||||
|
||||
/** 出库单可持久化明细 */
|
||||
export interface OutboundDetail {
|
||||
groupId?: number | string; // 组盘记录标识(库存选择方式)
|
||||
vehicleCode?: string; // 箱号(库存选择方式)
|
||||
materialCode: string; // 物料编码
|
||||
materialId?: string; // 物料标识
|
||||
pcsn?: string; // 批次序列号
|
||||
@@ -51,11 +53,9 @@ export namespace WmsIostorInvApi {
|
||||
|
||||
/** 出库明细页面展示模型(提交时应转换为 OutboundDetail) */
|
||||
export interface OutboundDisplayDetail extends OutboundDetail {
|
||||
groupId?: number | string;
|
||||
materialName?: string;
|
||||
rowKey?: string;
|
||||
sapBatchNo?: string;
|
||||
vehicleCode?: string;
|
||||
}
|
||||
|
||||
/** 出库单新增请求 */
|
||||
|
||||
@@ -59,6 +59,7 @@ describe('buildOutboundPayload', () => {
|
||||
expect(payload.bizDate).toBe(bizDate.valueOf());
|
||||
expect(payload.details).toEqual([
|
||||
{
|
||||
groupId: 11,
|
||||
materialCode: 'M-1',
|
||||
materialId: '1',
|
||||
pcsn: 'P-1',
|
||||
@@ -69,11 +70,22 @@ describe('buildOutboundPayload', () => {
|
||||
sourceBillCode: 'SRC-1',
|
||||
sourceBillType: 'SALE',
|
||||
sourceBilldtlId: 'D-1',
|
||||
vehicleCode: 'BOX-1',
|
||||
},
|
||||
]);
|
||||
expect(payload).not.toHaveProperty('detailCount');
|
||||
expect(payload).not.toHaveProperty('totalWeight');
|
||||
});
|
||||
|
||||
it('手工汇总行不附带库存引用字段', () => {
|
||||
const payload = buildOutboundPayload(
|
||||
{ billType: 'SALE', bizDate: 1, storId: 'S-1' },
|
||||
[{ materialCode: 'M-1', planQty: 2 }],
|
||||
);
|
||||
|
||||
expect(payload.details[0]?.groupId).toBeUndefined();
|
||||
expect(payload.details[0]?.vehicleCode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeOutboundInventory', () => {
|
||||
|
||||
@@ -50,6 +50,7 @@ export function buildOutboundPayload(
|
||||
billType: values.billType!,
|
||||
bizDate: Number(values.bizDate?.valueOf()),
|
||||
details: details.map((detail) => ({
|
||||
groupId: detail.groupId,
|
||||
materialCode: detail.materialCode,
|
||||
materialId: detail.materialId,
|
||||
pcsn: detail.pcsn,
|
||||
@@ -60,6 +61,7 @@ export function buildOutboundPayload(
|
||||
sourceBillCode: detail.sourceBillCode,
|
||||
sourceBillType: detail.sourceBillType,
|
||||
sourceBilldtlId: detail.sourceBilldtlId,
|
||||
vehicleCode: detail.vehicleCode,
|
||||
})),
|
||||
remark: values.remark || undefined,
|
||||
storId: values.storId!,
|
||||
|
||||
Reference in New Issue
Block a user