This commit is contained in:
2026-08-06 15:32:11 +08:00
parent f3cfbdf59b
commit 5f8b1f64da
21 changed files with 435 additions and 36 deletions
@@ -2,7 +2,14 @@ package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.PalletEntity;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/** 托盘主数据 Mapper。 */
public interface PalletMapper extends BaseMapper<PalletEntity> {
/** 编码序列必须包含逻辑删除记录,避免历史编码被复用。 */
@Select("SELECT pallet_code FROM wh_pallet WHERE tenant_id = #{tenantId} AND pallet_code LIKE 'TP-%'")
List<String> selectAllCodes(@Param("tenantId") long tenantId);
}
@@ -27,7 +27,6 @@ public class PalletRepository {
public PageResult<PalletEntity> page(Long tenantId, PalletRequests.PageQuery query) {
LambdaQueryWrapper<PalletEntity> wrapper = new LambdaQueryWrapper<PalletEntity>()
.eq(tenantId != null, PalletEntity::getTenantId, tenantId)
.eq(PalletEntity::getDeleted, false)
.eq(query.warehouseId() != null, PalletEntity::getWarehouseId, query.warehouseId())
.like(StringUtils.hasText(query.palletCode()), PalletEntity::getPalletCode, query.palletCode())
.eq(StringUtils.hasText(query.palletType()), PalletEntity::getPalletType, query.palletType())
@@ -41,7 +40,6 @@ public class PalletRepository {
return mapper.selectOne(new LambdaQueryWrapper<PalletEntity>()
.eq(PalletEntity::getTenantId, tenantId)
.eq(PalletEntity::getWarehouseId, warehouseId)
.eq(PalletEntity::getDeleted, false)
.eq(PalletEntity::getId, id));
}
@@ -50,7 +48,6 @@ public class PalletRepository {
.eq(PalletEntity::getTenantId, tenantId)
.eq(PalletEntity::getWarehouseId, warehouseId)
.eq(PalletEntity::getPalletCode, code)
.eq(PalletEntity::getDeleted, false)
.ne(excludedId != null, PalletEntity::getId, excludedId)) > 0;
}
@@ -59,7 +56,6 @@ public class PalletRepository {
return mapper.selectList(new LambdaQueryWrapper<PalletEntity>()
.eq(PalletEntity::getTenantId, tenantId)
.eq(PalletEntity::getWarehouseId, warehouseId)
.eq(PalletEntity::getDeleted, false)
.eq(PalletEntity::getStatus, "AVAILABLE")
.eq(StringUtils.hasText(palletType), PalletEntity::getPalletType, palletType)
.eq(capacityCbm != null, PalletEntity::getCapacityCbm, capacityCbm)
@@ -67,15 +63,13 @@ public class PalletRepository {
.last("LIMIT " + limit));
}
public String nextCode(long tenantId, long warehouseId) {
int maxSequence = mapper.selectList(new LambdaQueryWrapper<PalletEntity>()
.select(PalletEntity::getPalletCode)
.eq(PalletEntity::getTenantId, tenantId)
.eq(PalletEntity::getWarehouseId, warehouseId)
.likeRight(PalletEntity::getPalletCode, "TP-")
.eq(PalletEntity::getDeleted, false))
/**
* 生成平台作用域内下一个编码。编码序列跨仓库共用,且包含逻辑删除记录,确保编码永不复用。
*/
public String nextCode(long tenantId) {
List<String> existing = mapper.selectAllCodes(tenantId);
int maxSequence = (existing == null ? List.<String>of() : existing)
.stream()
.map(PalletEntity::getPalletCode)
.map(PALLET_CODE::matcher)
.filter(Matcher::matches)
.mapToInt(matcher -> Integer.parseInt(matcher.group(1)))
@@ -16,6 +16,8 @@ import org.springframework.transaction.annotation.Transactional;
/** 托盘主数据应用服务。 */
@Service
public class PalletApplicationService {
private static final int CODE_INSERT_ATTEMPTS = 5;
@Resource
private PalletRepository repository;
@Resource
@@ -34,19 +36,23 @@ public class PalletApplicationService {
@Transactional
@BusinessAudit(event = "PALLET_CREATE", resourceType = "PALLET", description = "新增托盘", captureBefore = false)
public Long create(long tenantId, PalletRequests.SaveRequest request) {
requireWarehouse(tenantId, request.warehouseId());
requireWarehouse(tenantId, request.warehouseId(), true);
PalletEntity entity = new PalletEntity();
entity.setTenantId(tenantId);
entity.setWarehouseId(request.warehouseId());
entity.setPalletCode(repository.nextCode(tenantId, request.warehouseId()));
copy(entity, request);
entity.setStatus("AVAILABLE");
try {
repository.insert(entity);
} catch (DataIntegrityViolationException exception) {
throw new BizException(ResultCode.CONFLICT, "托盘编码已存在");
String code = repository.nextCode(tenantId);
for (int attempt = 0; attempt < CODE_INSERT_ATTEMPTS; attempt++) {
entity.setPalletCode(code);
try {
repository.insert(entity);
return entity.getId();
} catch (DataIntegrityViolationException exception) {
code = incrementCode(code);
}
}
return entity.getId();
throw new BizException(ResultCode.CONFLICT, "托盘编码生成冲突,请重试");
}
@Transactional
@@ -55,6 +61,7 @@ public class PalletApplicationService {
if (warehouseId != request.warehouseId()) {
throw new BizException(ResultCode.FORBIDDEN, "请求仓库与托盘所属仓库不一致");
}
requireWarehouse(tenantId, warehouseId, false);
PalletEntity entity = owned(tenantId, warehouseId, id);
copy(entity, request);
try {
@@ -83,10 +90,17 @@ public class PalletApplicationService {
return entity;
}
private void requireWarehouse(long tenantId, long warehouseId) {
if (warehouseRepository.findOwned(tenantId, warehouseId) == null) {
private void requireWarehouse(long tenantId, long warehouseId, boolean requireActive) {
var warehouse = warehouseRepository.findOwned(tenantId, warehouseId);
if (warehouse == null) {
throw new BizException(ResultCode.FORBIDDEN, "仓库不存在或不属于目标租户");
}
if (!"DOMESTIC".equalsIgnoreCase(warehouse.getWarehouseType())) {
throw new BizException(ResultCode.FORBIDDEN, "托盘仅支持国内仓");
}
if (requireActive && !"ACTIVE".equalsIgnoreCase(warehouse.getStatus())) {
throw new BizException(ResultCode.FORBIDDEN, "新增托盘仅支持启用中的国内仓");
}
}
private static void copy(PalletEntity entity, PalletRequests.SaveRequest request) {
@@ -97,6 +111,11 @@ public class PalletApplicationService {
entity.setRemark(request.remark());
}
private static String incrementCode(String code) {
int sequence = Integer.parseInt(code.substring("TP-".length())) + 1;
return "TP-%03d".formatted(sequence);
}
private static PalletRequests.View view(PalletEntity entity) {
return new PalletRequests.View(entity.getId(), entity.getTenantId(), entity.getWarehouseId(),
entity.getPalletCode(), entity.getPalletType(), entity.getCapacityCbm(), entity.getWeightKg(),
@@ -9,6 +9,7 @@ SET @sql = IF((SELECT COUNT(*) FROM information_schema.columns
'ALTER TABLE wh_pallet ADD COLUMN weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''托盘重量kg'' AFTER capacity_cbm',
'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' AND column_name = 'color') = 0,
'ALTER TABLE wh_pallet ADD COLUMN color VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''托盘颜色'' AFTER weight_kg',
@@ -53,6 +54,35 @@ UPDATE wh_location SET tenant_id = 0 WHERE tenant_id <> 0;
UPDATE wh_location_reservation SET tenant_id = 0 WHERE tenant_id <> 0;
UPDATE wh_location_occupancy SET tenant_id = 0 WHERE tenant_id <> 0;
-- 托盘编码在平台公共作用域全局唯一,避免不同仓库并发生成重复编码。
DROP PROCEDURE IF EXISTS assert_pallet_code_globally_unique;
DELIMITER $$
CREATE PROCEDURE assert_pallet_code_globally_unique()
BEGIN
IF EXISTS (
SELECT 1 FROM wh_pallet
GROUP BY tenant_id, pallet_code
HAVING COUNT(*) > 1
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '托盘编码迁移失败:不同仓库存在重复编码,请先清理';
END IF;
END$$
DELIMITER ;
CALL assert_pallet_code_globally_unique();
DROP PROCEDURE IF EXISTS assert_pallet_code_globally_unique;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'wh_pallet'
AND index_name = 'uk_pallet_tenant_warehouse_code') > 0,
'ALTER TABLE wh_pallet DROP INDEX uk_pallet_tenant_warehouse_code', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'wh_pallet'
AND index_name = 'uk_pallet_tenant_code') = 0,
'ALTER TABLE wh_pallet ADD UNIQUE KEY uk_pallet_tenant_code (tenant_id, pallet_code)', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'wh_location_reservation'
@@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet (
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_pallet_tenant_warehouse_code (tenant_id, warehouse_id, pallet_code),
UNIQUE KEY uk_pallet_tenant_code (tenant_id, pallet_code),
KEY idx_pallet_available (tenant_id, warehouse_id, status, deleted),
KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, status, deleted),
CONSTRAINT fk_pallet_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id),
@@ -0,0 +1,17 @@
-- 二期托盘初始化数据。执行顺序:warehouse-seed.sql、phase2-pallet-location-schema.sql、增量迁移、本文件。
SET NAMES utf8mb4;
USE baiyue_warehouse;
-- 编码由平台维护接口自动生成;种子固定为 TP-001、TP-002。
INSERT INTO wh_pallet(id,tenant_id,warehouse_id,pallet_code,pallet_type,capacity_cbm,weight_kg,color,status,remark,created_by,updated_by)
VALUES
(190000000000000101,0,190000000000000001,'TP-001','木托盘',1.200000,8.000,'黄色','AVAILABLE',NULL,0,0),
(190000000000000102,0,190000000000000001,'TP-002','胶托盘',2.400000,12.000,'黑色','AVAILABLE',NULL,0,0)
ON DUPLICATE KEY UPDATE
tenant_id=0,
warehouse_id=VALUES(warehouse_id),
pallet_type=VALUES(pallet_type),
capacity_cbm=VALUES(capacity_cbm),
weight_kg=VALUES(weight_kg),
color=VALUES(color),
remark=VALUES(remark);
@@ -11,13 +11,6 @@ INSERT INTO wh_service_capability(id,tenant_id,warehouse_id,capability_code,capa
VALUES(190000000000000021,0,190000000000000001,'RECEIVING','头程收货',1,'16:00:00','工作日16点前预约可当日收货')
ON DUPLICATE KEY UPDATE tenant_id=0,enabled=1,cutoff_time=VALUES(cutoff_time);
-- 二期托盘初始化数据。编码由平台维护接口自动生成,种子固定为 TP-001、TP-002。
INSERT INTO wh_pallet(id,tenant_id,warehouse_id,pallet_code,pallet_type,capacity_cbm,weight_kg,color,status,remark,created_by,updated_by)
VALUES
(190000000000000101,0,190000000000000001,'TP-001','木托盘',1.200000,8.000,'黄色','AVAILABLE','无备注',0,0),
(190000000000000102,0,190000000000000001,'TP-002','胶托盘',2.400000,12.000,'黑色','AVAILABLE','无备注',0,0)
ON DUPLICATE KEY UPDATE tenant_id=0,warehouse_id=VALUES(warehouse_id),pallet_type=VALUES(pallet_type),capacity_cbm=VALUES(capacity_cbm),weight_kg=VALUES(weight_kg),color=VALUES(color),remark=VALUES(remark);
-- 一期联调快递公司主数据,供平台维护和订单履约回显验证。
INSERT INTO wh_express_company(id,tenant_id,company_code,company_name,service_phone,website,tracking_url_template,status,remark)
VALUES(192000000000000001,0,'SF','顺丰速运','95338','https://www.sf-express.com','https://www.sf-express.com/chn/sc/dynamic_function/waybill/#search/bill-number/{trackingNo}','ACTIVE','平台公共快递公司样本')
@@ -43,6 +43,9 @@ class WarehouseSchemaContractTest {
@Test
void shouldHandleCase04() throws IOException {
String sql = read("phase2-pallet-location-schema.sql");
assertColumns(sql, "wh_pallet", "tenant_id", "warehouse_id", "pallet_code", "pallet_type",
"capacity_cbm", "weight_kg", "color", "status", "remark", "deleted", "version");
assertThat(sql).contains("uk_pallet_tenant_code (tenant_id, pallet_code)");
assertColumns(sql, "wh_location", "tenant_id", "warehouse_id", "zone_code", "zone_name",
"location_code", "location_type", "max_weight_kg", "shared_overflow", "status", "remark", "deleted", "version");
assertColumns(sql, "wh_location_reservation", "idempotency_key", "business_id", "pallet_id",
@@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.warehouse.dto.PalletRequests;
import com.baiyue.warehouse.entity.PalletEntity;
import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.repository.PalletRepository;
import com.baiyue.warehouse.repository.WarehouseRepository;
import org.junit.jupiter.api.Test;
@@ -16,6 +17,8 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
@SpringJUnitConfig(PalletApplicationService.class)
class PalletApplicationServiceTest {
@MockBean private PalletRepository repository;
@@ -46,6 +49,20 @@ class PalletApplicationServiceTest {
verify(repository).page(null, query);
}
@Test
void rejectsOverseasWarehouseForCreate() {
WarehouseEntity warehouse = new WarehouseEntity();
warehouse.setWarehouseType("OVERSEAS");
warehouse.setStatus("ACTIVE");
when(warehouseRepository.findOwned(0, 20)).thenReturn(warehouse);
PalletRequests.SaveRequest request = new PalletRequests.SaveRequest(
20L, "木托盘", new BigDecimal("1.2"), new BigDecimal("8"), "黄色", null);
assertThatThrownBy(() -> service.create(0, request))
.isInstanceOf(BizException.class)
.hasMessageContaining("仅支持国内仓");
}
private static PalletEntity pallet(String status) {
PalletEntity entity = new PalletEntity();
entity.setId(30L);