This commit is contained in:
2026-08-17 11:23:28 +08:00
parent d51e054def
commit 6b416621bb
19 changed files with 327 additions and 222 deletions
@@ -104,7 +104,7 @@ public final class LocationDtos {
public record OccupancyTarget(
@Min(1) long reservationId,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal allocatedVolumeCbm,
@Min(1) Integer allocatedPackageCount
@Min(0) Integer allocatedPackageCount
) {
}
@@ -25,13 +25,19 @@ public final class PalletAllocationDtos {
@NotBlank @Size(max = 128) String idempotencyKey,
@Min(1) long operatorId,
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6) BigDecimal totalVolumeCbm,
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6) BigDecimal palletCapacityCbm,
@Size(max = 32) String palletType,
@NotNull @Size(min = 1, max = 200) List<@NotNull @jakarta.validation.Valid PalletReference> pallets,
@NotNull LocalDateTime expiresAt,
@NotNull Boolean measurementCompleted
) {
}
/** 一张已确认称重图片对应一托;同一托盘主数据允许重复引用。 */
public record PalletReference(
@Min(1) long weightResultId,
@Min(1) long palletId
) {
}
public record ConfirmRequest(
@Min(0) long tenantId,
@Min(1) long warehouseId,
@@ -39,7 +45,6 @@ public final class PalletAllocationDtos {
@Min(1) long businessId,
@Min(1) long operatorId,
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6) BigDecimal totalVolumeCbm,
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6) BigDecimal palletCapacityCbm,
@Min(1) int packageCount
) {
}
@@ -193,6 +198,7 @@ public final class PalletAllocationDtos {
int allocationNo,
long palletId,
String palletCode,
BigDecimal palletCapacityCbm,
long locationId,
String locationCode,
BigDecimal allocatedVolumeCbm,
@@ -62,16 +62,6 @@ public class PalletRepository {
.ne(excludedId != null, PalletEntity::getId, excludedId)) > 0;
}
public List<PalletEntity> findReusable(long tenantId, long warehouseId, String palletType,
java.math.BigDecimal capacityCbm) {
return mapper.selectList(new LambdaQueryWrapper<PalletEntity>()
.eq(PalletEntity::getTenantId, tenantId)
.eq(PalletEntity::getWarehouseId, warehouseId)
.eq(StringUtils.hasText(palletType), PalletEntity::getPalletType, palletType)
.eq(capacityCbm != null, PalletEntity::getCapacityCbm, capacityCbm)
.orderByAsc(PalletEntity::getPalletCode));
}
/**
* 生成平台作用域内下一个编码。编码序列跨仓库共用,且包含逻辑删除记录,确保编码永不复用。
*/
@@ -32,8 +32,6 @@ import java.security.NoSuchAlgorithmException;
*/
@Service
public class PalletLocationAllocationService {
/** 测量完成后默认推荐的普通托盘类型编码。 */
public static final String DEFAULT_PALLET_TYPE = "STANDARD";
/** 仓库、托盘和库位属于平台公共资源域。 */
private static final long PLATFORM_RESOURCE_TENANT_ID = 0L;
/** 仅头程服务可结算头程入库单占用的仓储资源。 */
@@ -41,8 +39,6 @@ public class PalletLocationAllocationService {
@Resource
private PalletRepository palletRepository;
@Resource
private PalletVolumeSplitter splitter;
@Resource
private LocationResourceService locationResourceService;
@Resource
private PalletLocationTransferOperationRepository transferOperations;
@@ -59,74 +55,78 @@ public class PalletLocationAllocationService {
if (!Boolean.TRUE.equals(request.measurementCompleted())) {
throw new BizException(ResultCode.CONFLICT, "只能使用已完成测量的总体积拆分托盘并占用库位");
}
var split = splitter.split(request.totalVolumeCbm(), request.palletCapacityCbm());
List<LocationDtos.ActiveResource> existing = active(request.tenantId(), request.sourceService(), request.businessId());
if (!existing.isEmpty()) {
return fromActive(existing, split);
return fromActive(request.tenantId(), request.warehouseId(), existing, request.totalVolumeCbm());
}
String palletType = org.springframework.util.StringUtils.hasText(request.palletType())
? request.palletType() : DEFAULT_PALLET_TYPE;
List<PalletEntity> pallets = palletRepository.findReusable(request.tenantId(), request.warehouseId(),
palletType, request.palletCapacityCbm());
if (pallets.isEmpty()) {
throw new BizException(ResultCode.NOT_FOUND, "未配置匹配类型和容量的托盘主数据");
List<PalletEntity> pallets = new ArrayList<>(request.pallets().size());
for (PalletAllocationDtos.PalletReference reference : request.pallets()) {
PalletEntity pallet = palletRepository.findOwned(request.tenantId(), request.warehouseId(), reference.palletId());
if (pallet == null) {
throw new BizException(ResultCode.NOT_FOUND, "称重图片关联的托盘主数据不存在或不属于当前仓库");
}
pallets.add(pallet);
}
List<LocationDtos.ReservationTarget> targets = new ArrayList<>(split.palletCount());
for (int index = 0; index < split.palletCount(); index++) {
PalletEntity pallet = pallets.get(index % pallets.size());
List<LocationDtos.ReservationTarget> targets = new ArrayList<>(pallets.size());
for (int index = 0; index < pallets.size(); index++) {
PalletEntity pallet = pallets.get(index);
targets.add(new LocationDtos.ReservationTarget(pallet.getId(), pallet.getPalletCode(),
request.idempotencyKey() + ":" + (index + 1)));
request.idempotencyKey() + ":" + request.pallets().get(index).weightResultId()));
}
List<LocationDtos.ReservationResult> reservations = locationResourceService.reserveAvailable(
new LocationDtos.ReserveCommand(request.tenantId(), request.warehouseId(), request.sourceService(),
request.businessId(), request.inboundCode(), request.operatorId(), request.expiresAt(), targets));
if (reservations.size() != split.palletCount()) {
throw new BizException(ResultCode.CONFLICT, "库位预占结果数量不一致");
if (reservations.size() != pallets.size()) {
throw new BizException(ResultCode.CONFLICT, "库位预占结果数量与已确认称重图片数量不一致");
}
List<PalletAllocationDtos.AllocationResult> results = new ArrayList<>(reservations.size());
for (int index = 0; index < reservations.size(); index++) {
var item = reservations.get(index);
results.add(new PalletAllocationDtos.AllocationResult(item.reservationId(), item.reservationNo(), index + 1,
item.palletId(), item.palletCode(), item.locationId(), item.locationCode(),
split.allocations().get(index).allocatedVolumeCbm(), "RESERVED"));
item.palletId(), item.palletCode(), pallets.get(index).getCapacityCbm(),
item.locationId(), item.locationCode(), allocatedVolume(request.totalVolumeCbm(), reservations.size(), index),
"RESERVED"));
}
return new PalletAllocationDtos.BatchResult(split.palletCount(), List.copyOf(results));
return new PalletAllocationDtos.BatchResult(results.size(), List.copyOf(results));
}
@Transactional
@BusinessAudit(event = "PALLET_LOCATION_CONFIRM", resourceType = "INBOUND", resourceId = "#request.businessId", description = "确认库位占用")
public PalletAllocationDtos.BatchResult confirm(PalletAllocationDtos.ConfirmRequest request) {
var split = splitter.split(request.totalVolumeCbm(), request.palletCapacityCbm());
List<LocationDtos.ActiveResource> active = active(request.tenantId(), request.sourceService(), request.businessId());
if (active.size() != split.palletCount()) {
throw new BizException(ResultCode.CONFLICT, "活动预占数量与托盘拆分数量不一致");
}
if (active.size() > request.packageCount()) {
throw new BizException(ResultCode.CONFLICT, "托盘数量超过入库单总箱数,无法为每个托盘分配至少一箱");
if (active.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "入库单没有可确认的活动库位预占");
}
active.sort(Comparator.comparingLong(LocationDtos.ActiveResource::reservationId));
List<LocationDtos.OccupancyTarget> targets = new ArrayList<>(active.size());
int basePackageCount = request.packageCount() / active.size();
int remainder = request.packageCount() % active.size();
int packageRemainder = request.packageCount() % active.size();
for (int index = 0; index < active.size(); index++) {
int allocatedPackages = basePackageCount + (index < remainder ? 1 : 0);
targets.add(new LocationDtos.OccupancyTarget(active.get(index).reservationId(),
split.allocations().get(index).allocatedVolumeCbm(), allocatedPackages));
allocatedVolume(request.totalVolumeCbm(), active.size(), index),
basePackageCount + (index < packageRemainder ? 1 : 0)));
}
List<LocationDtos.OccupancyResult> occupied = locationResourceService.confirmOccupied(
new LocationDtos.ConfirmCommand(request.tenantId(), request.warehouseId(), request.sourceService(),
request.businessId(), request.operatorId(), targets));
if (occupied.size() != active.size()) {
throw new BizException(ResultCode.CONFLICT, "库位占用结果数量与已确认称重图片数量不一致");
}
List<PalletAllocationDtos.AllocationResult> results = new ArrayList<>(occupied.size());
for (int index = 0; index < occupied.size(); index++) {
var item = occupied.get(index);
PalletEntity pallet = palletRepository.findOwnedIncludingDeleted(request.tenantId(), request.warehouseId(), item.palletId());
if (pallet == null) {
throw new BizException(ResultCode.NOT_FOUND, "库位占用引用的托盘主数据不存在");
}
results.add(new PalletAllocationDtos.AllocationResult(item.reservationId(), active.get(index).reservationNo(), index + 1,
item.palletId(), active.get(index).palletCode(), item.locationId(), item.locationCode(),
split.allocations().get(index).allocatedVolumeCbm(), "OCCUPIED"));
item.palletId(), active.get(index).palletCode(), pallet.getCapacityCbm(), item.locationId(), item.locationCode(),
allocatedVolume(request.totalVolumeCbm(), occupied.size(), index), "OCCUPIED"));
}
return new PalletAllocationDtos.BatchResult(split.palletCount(), List.copyOf(results));
return new PalletAllocationDtos.BatchResult(results.size(), List.copyOf(results));
}
@Transactional
@@ -528,19 +528,27 @@ public class PalletLocationAllocationService {
return locationResourceService.findOccupiedResources(tenantId, sourceService, businessId);
}
private static PalletAllocationDtos.BatchResult fromActive(List<LocationDtos.ActiveResource> active,
PalletVolumeSplitter.SplitResult split) {
if (active.size() != split.palletCount()) {
throw new BizException(ResultCode.CONFLICT, "幂等请求与已有托盘拆分结果不一致");
}
private PalletAllocationDtos.BatchResult fromActive(long tenantId, long warehouseId,
List<LocationDtos.ActiveResource> active,
java.math.BigDecimal totalVolumeCbm) {
active.sort(Comparator.comparingLong(LocationDtos.ActiveResource::reservationId));
List<PalletAllocationDtos.AllocationResult> results = new ArrayList<>(active.size());
for (int index = 0; index < active.size(); index++) {
var item = active.get(index);
PalletEntity pallet = palletRepository.findOwnedIncludingDeleted(
tenantId, warehouseId, item.palletId());
if (pallet == null) {
throw new BizException(ResultCode.NOT_FOUND, "已有库位分配引用的托盘主数据不存在");
}
results.add(new PalletAllocationDtos.AllocationResult(item.reservationId(), item.reservationNo(), index + 1,
item.palletId(), item.palletCode(), item.locationId(), item.locationCode(),
split.allocations().get(index).allocatedVolumeCbm(), item.state()));
item.palletId(), item.palletCode(), pallet.getCapacityCbm(), item.locationId(), item.locationCode(),
allocatedVolume(totalVolumeCbm, active.size(), index), item.state()));
}
return new PalletAllocationDtos.BatchResult(split.palletCount(), List.copyOf(results));
return new PalletAllocationDtos.BatchResult(results.size(), List.copyOf(results));
}
private static java.math.BigDecimal allocatedVolume(java.math.BigDecimal total, int count, int index) {
java.math.BigDecimal base = total.divide(java.math.BigDecimal.valueOf(count), 6, java.math.RoundingMode.DOWN);
return index == count - 1 ? total.subtract(base.multiply(java.math.BigDecimal.valueOf(count - 1L))) : base;
}
}
@@ -1,52 +0,0 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
/** 按托盘容积向上拆分入库总体积,不涉及SKU库存。 */
@Component
public class PalletVolumeSplitter {
static final int MAX_PALLET_COUNT = 200;
public SplitResult split(BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm) {
requirePositive(totalVolumeCbm, "总体积必须大于0");
requirePositive(palletCapacityCbm, "托盘容积必须大于0");
final int palletCount;
try {
palletCount = totalVolumeCbm.divide(palletCapacityCbm, 0, RoundingMode.CEILING).intValueExact();
} catch (ArithmeticException exception) {
throw new BizException(ResultCode.VALIDATION_FAILED, "托盘拆分数量超出系统处理范围");
}
if (palletCount > MAX_PALLET_COUNT) {
throw new BizException(ResultCode.VALIDATION_FAILED, "单次最多拆分200个托盘");
}
List<Allocation> allocations = new ArrayList<>(palletCount);
for (int index = 1; index <= palletCount; index++) {
BigDecimal allocated = index == palletCount
? totalVolumeCbm.subtract(palletCapacityCbm.multiply(BigDecimal.valueOf(palletCount - 1L)))
: palletCapacityCbm;
allocations.add(new Allocation(index, allocated));
}
return new SplitResult(totalVolumeCbm, palletCapacityCbm, palletCount, List.copyOf(allocations));
}
private static void requirePositive(BigDecimal value, String message) {
if (value == null || value.signum() <= 0) {
throw new BizException(ResultCode.VALIDATION_FAILED, message);
}
}
public record Allocation(int allocationNo, BigDecimal allocatedVolumeCbm) {
}
public record SplitResult(BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm,
int palletCount, List<Allocation> allocations) {
}
}
@@ -16,14 +16,56 @@ ON DUPLICATE KEY UPDATE
color=VALUES(color),
remark=VALUES(remark);
-- 每个国内作业仓初始化一个普通库位和一个系统共享溢出位 A0。
-- 义乌仓初始化 A-F 六个库区,每区 A1-A15 共 90 个普通库位。
-- A0 仍是系统共享溢出位;东莞仓继续保留最小 A1 + A0 基线。
-- 这里只创建公共资源,不初始化预占、占用或业务单据。
INSERT INTO wh_location(
id,tenant_id,warehouse_id,zone_code,zone_name,location_code,location_name,
location_type,max_weight_kg,shared_overflow,status,remark,created_by,updated_by
)
WITH RECURSIVE
zones AS (
SELECT 0 AS zone_index
UNION ALL
SELECT zone_index + 1 FROM zones WHERE zone_index < 5
),
slots AS (
SELECT 1 AS slot_number
UNION ALL
SELECT slot_number + 1 FROM slots WHERE slot_number < 15
)
SELECT
CAST(190000000000000000 + CASE
WHEN zones.zone_index = 0 AND slots.slot_number = 1 THEN 201
ELSE 203 + zones.zone_index * 15 + slots.slot_number
END AS UNSIGNED),
0,
190000000000000001,
CHAR(65 + zones.zone_index),
CONCAT(CHAR(65 + zones.zone_index), '区'),
CONCAT(CHAR(65 + zones.zone_index), slots.slot_number),
CONCAT(CHAR(65 + zones.zone_index), slots.slot_number, ' 普通库位'),
'PALLET',1000.000,0,'AVAILABLE',NULL,0,0
FROM zones
CROSS JOIN slots
ON DUPLICATE KEY UPDATE
tenant_id=0,
warehouse_id=VALUES(warehouse_id),
zone_code=VALUES(zone_code),
zone_name=VALUES(zone_name),
location_name=VALUES(location_name),
location_type=VALUES(location_type),
max_weight_kg=VALUES(max_weight_kg),
shared_overflow=VALUES(shared_overflow),
status='AVAILABLE',
remark=VALUES(remark),
deleted=0;
INSERT INTO wh_location(
id,tenant_id,warehouse_id,zone_code,zone_name,location_code,location_name,
location_type,max_weight_kg,shared_overflow,status,remark,created_by,updated_by
)
VALUES
(190000000000000201,0,190000000000000001,'A','A区','A1','A1 普通库位','PALLET',1000.000,0,'AVAILABLE',NULL,0,0),
(190000000000000202,0,190000000000000001,'A','A区','A0','A0 共享溢出位','PALLET',NULL,1,'AVAILABLE','系统共享临时溢出位',0,0),
(190000000000000203,0,190000000000000002,'A','A区','A1','A1 普通库位','PALLET',1000.000,0,'AVAILABLE',NULL,0,0),
(190000000000000204,0,190000000000000002,'A','A区','A0','A0 共享溢出位','PALLET',NULL,1,'AVAILABLE','系统共享临时溢出位',0,0)
@@ -39,3 +81,32 @@ ON DUPLICATE KEY UPDATE
status='AVAILABLE',
remark=VALUES(remark),
deleted=0;
DROP PROCEDURE IF EXISTS assert_yiwu_location_seed;
DELIMITER //
CREATE PROCEDURE assert_yiwu_location_seed()
BEGIN
IF (SELECT COUNT(*)
FROM wh_location
WHERE tenant_id=0
AND warehouse_id=190000000000000001
AND deleted=0
AND shared_overflow=0
AND zone_code IN ('A','B','C','D','E','F')
AND location_code REGEXP '^[A-F]([1-9]|1[0-5])$'
AND LEFT(location_code,1)=zone_code) <> 90 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='义乌仓 A-F 每区 15 个普通库位初始化不完整';
END IF;
IF (SELECT COUNT(*)
FROM wh_location
WHERE tenant_id=0
AND warehouse_id=190000000000000001
AND location_code='A0'
AND shared_overflow=1
AND deleted=0) <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='义乌仓系统共享溢出位 A0 初始化不完整';
END IF;
END//
DELIMITER ;
CALL assert_yiwu_location_seed();
DROP PROCEDURE IF EXISTS assert_yiwu_location_seed;