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
@@ -6183,14 +6183,56 @@ ON DUPLICATE KEY UPDATE
color = VALUES(color), color = VALUES(color),
remark = VALUES(remark); remark = VALUES(remark);
-- 国内作业仓最小库位基线:每仓一个普通 A1 和一个系统共享溢出位 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(193000000000200000 + 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,
193000000000200001,
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 ( INSERT INTO wh_location (
id, tenant_id, warehouse_id, zone_code, zone_name, location_code, location_name, 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 location_type, max_weight_kg, shared_overflow, status, remark, created_by, updated_by
) )
VALUES VALUES
(193000000000200201,0,193000000000200001,'A','A区','A1','A1 普通库位','PALLET',1000.000,0,'AVAILABLE',NULL,0,0),
(193000000000200202,0,193000000000200001,'A','A区','A0','A0 共享溢出位','PALLET',NULL,1,'AVAILABLE','系统共享临时溢出位',0,0), (193000000000200202,0,193000000000200001,'A','A区','A0','A0 共享溢出位','PALLET',NULL,1,'AVAILABLE','系统共享临时溢出位',0,0),
(193000000000200203,0,193000000000200002,'A','A区','A1','A1 普通库位','PALLET',1000.000,0,'AVAILABLE',NULL,0,0), (193000000000200203,0,193000000000200002,'A','A区','A1','A1 普通库位','PALLET',1000.000,0,'AVAILABLE',NULL,0,0),
(193000000000200204,0,193000000000200002,'A','A区','A0','A0 共享溢出位','PALLET',NULL,1,'AVAILABLE','系统共享临时溢出位',0,0) (193000000000200204,0,193000000000200002,'A','A区','A0','A0 共享溢出位','PALLET',NULL,1,'AVAILABLE','系统共享临时溢出位',0,0)
@@ -6207,6 +6249,35 @@ ON DUPLICATE KEY UPDATE
remark = VALUES(remark), remark = VALUES(remark),
deleted = 0; 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 = 193000000000200001
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 = 193000000000200001
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;
DROP TEMPORARY TABLE IF EXISTS tmp_wh_fba_public_master_data; DROP TEMPORARY TABLE IF EXISTS tmp_wh_fba_public_master_data;
CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data ( CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data (
warehouse_id_seed BIGINT UNSIGNED NOT NULL, warehouse_id_seed BIGINT UNSIGNED NOT NULL,
@@ -59,18 +59,19 @@ public interface WarehousePalletLocationClient {
record ReserveRequest(long tenantId, long warehouseId, String sourceService, long businessId, record ReserveRequest(long tenantId, long warehouseId, String sourceService, long businessId,
String inboundCode, String idempotencyKey, long operatorId, String inboundCode, String idempotencyKey, long operatorId,
BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm, String palletType, BigDecimal totalVolumeCbm, List<PalletReference> pallets,
LocalDateTime expiresAt, boolean measurementCompleted) {} LocalDateTime expiresAt, boolean measurementCompleted) {}
record PalletReference(long weightResultId, long palletId) {}
record ConfirmRequest(long tenantId, long warehouseId, String sourceService, long businessId, record ConfirmRequest(long tenantId, long warehouseId, String sourceService, long businessId,
long operatorId, BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm, long operatorId, BigDecimal totalVolumeCbm, int packageCount) {}
int packageCount) {}
record ReleaseRequest(long tenantId, String sourceService, long businessId, long operatorId, record ReleaseRequest(long tenantId, String sourceService, long businessId, long operatorId,
String reason) {} String reason) {}
record SelectiveReleaseRequest(long tenantId, String sourceService, long businessId, record SelectiveReleaseRequest(long tenantId, String sourceService, long businessId,
String idempotencyKey, long operatorId, String reason, String idempotencyKey, long operatorId, String reason,
List<Long> locationIds) {} List<Long> locationIds) {}
record AllocationResult(long reservationId, String reservationNo, int allocationNo, record AllocationResult(long reservationId, String reservationNo, int allocationNo,
long palletId, String palletCode, long locationId, String locationCode, long palletId, String palletCode, BigDecimal palletCapacityCbm,
long locationId, String locationCode,
BigDecimal allocatedVolumeCbm, String state) {} BigDecimal allocatedVolumeCbm, String state) {}
record BatchResult(int palletCount, List<AllocationResult> allocations) {} record BatchResult(int palletCount, List<AllocationResult> allocations) {}
record ActiveResource(long reservationId, long warehouseId, long palletId, String palletCode, record ActiveResource(long reservationId, long warehouseId, long palletId, String palletCode,
@@ -22,7 +22,7 @@ public class InboundPalletAllocationRepository {
@Transactional @Transactional
public void replace(long tenantId, long inboundId, long warehouseId, long warehouseResourceTenantId, public void replace(long tenantId, long inboundId, long warehouseId, long warehouseResourceTenantId,
BigDecimal capacity, int totalPackageCount, BatchResult result) { int totalPackageCount, BatchResult result) {
Map<Long, String> reservationNos = mapper.selectList(new LambdaQueryWrapper<InboundPalletAllocationEntity>() Map<Long, String> reservationNos = mapper.selectList(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId) .eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId)) .eq(InboundPalletAllocationEntity::getInboundId, inboundId))
@@ -33,9 +33,6 @@ public class InboundPalletAllocationRepository {
.eq(InboundPalletAllocationEntity::getInboundId, inboundId)); .eq(InboundPalletAllocationEntity::getInboundId, inboundId));
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
int allocationCount = result.allocations().size(); int allocationCount = result.allocations().size();
if (allocationCount > totalPackageCount) {
throw new IllegalArgumentException("托盘数量不能超过入库单总箱数");
}
int basePackageCount = allocationCount == 0 ? 0 : totalPackageCount / allocationCount; int basePackageCount = allocationCount == 0 ? 0 : totalPackageCount / allocationCount;
int remainder = allocationCount == 0 ? 0 : totalPackageCount % allocationCount; int remainder = allocationCount == 0 ? 0 : totalPackageCount % allocationCount;
for (int index = 0; index < result.allocations().size(); index++) { for (int index = 0; index < result.allocations().size(); index++) {
@@ -48,7 +45,7 @@ public class InboundPalletAllocationRepository {
value.setWarehouseResourceTenantId(warehouseResourceTenantId); value.setWarehouseResourceTenantId(warehouseResourceTenantId);
value.setPalletId(source.palletId()); value.setPalletId(source.palletId());
value.setPalletCode(source.palletCode()); value.setPalletCode(source.palletCode());
value.setPalletCapacityCbm(capacity); value.setPalletCapacityCbm(source.palletCapacityCbm());
value.setAllocatedVolumeCbm(source.allocatedVolumeCbm()); value.setAllocatedVolumeCbm(source.allocatedVolumeCbm());
int allocatedPackages = basePackageCount + (index < remainder ? 1 : 0); int allocatedPackages = basePackageCount + (index < remainder ? 1 : 0);
value.setAllocatedPackageCount(allocatedPackages); value.setAllocatedPackageCount(allocatedPackages);
@@ -117,6 +117,9 @@ public class InboundMeasurementService {
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId()); List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId); List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
BigDecimal confirmedWeight = confirmedWeightForSubmission(weights); BigDecimal confirmedWeight = confirmedWeightForSubmission(weights);
int confirmedPalletCount = Math.toIntExact(weights.stream()
.filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))
.count());
if (!alreadyAdvanced) { if (!alreadyAdvanced) {
requireConfirmedPackageTotal(inbound, items); requireConfirmedPackageTotal(inbound, items);
@@ -137,6 +140,7 @@ public class InboundMeasurementService {
if (confirmedWeight != null) { if (confirmedWeight != null) {
inbound.setActualGrossWeightKg(confirmedWeight); inbound.setActualGrossWeightKg(confirmedWeight);
} }
inbound.setPalletCount(confirmedPalletCount);
if (measurement.getTotalVolumeCbm() != null) { if (measurement.getTotalVolumeCbm() != null) {
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm()); inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
} }
@@ -8,6 +8,8 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehousePalletLocationClient; import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import com.baiyue.firstleg.config.InboundProperties; import com.baiyue.firstleg.config.InboundProperties;
import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository; import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
import com.baiyue.firstleg.repository.InboundWorkflowRepository; import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import feign.FeignException; import feign.FeignException;
@@ -29,6 +31,8 @@ public class MeasurementLocationOccupationService {
@Resource @Resource
private InboundWorkflowRepository workflow; private InboundWorkflowRepository workflow;
@Resource @Resource
private InboundMeasurementRepository measurements;
@Resource
private InboundProperties properties; private InboundProperties properties;
@Value("${baiyue.security.internal-service-token}") @Value("${baiyue.security.internal-service-token}")
private String internalToken; private String internalToken;
@@ -39,6 +43,7 @@ public class MeasurementLocationOccupationService {
long warehouseId = requireWarehouse(order); long warehouseId = requireWarehouse(order);
long resourceTenantId = PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID; long resourceTenantId = PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID;
BigDecimal volume = requireVolume(order); BigDecimal volume = requireVolume(order);
var palletReferences = confirmedPalletReferences(tenantId, inboundId);
// 补偿释放后的预占记录需要保留审计,失败重试必须使用新的物理预占键; // 补偿释放后的预占记录需要保留审计,失败重试必须使用新的物理预占键;
// 若仍存在活动预占,仓储侧会按业务单据复用,不会创建新的资源占用。 // 若仍存在活动预占,仓储侧会按业务单据复用,不会创建新的资源占用。
String key = "measure-" + inboundId + "-" + measurementId + "-" + measurementVersion String key = "measure-" + inboundId + "-" + measurementId + "-" + measurementVersion
@@ -46,19 +51,16 @@ public class MeasurementLocationOccupationService {
WarehousePalletLocationClient.BatchResult reserved = requireSuccess(call(() -> warehouse.reserve( WarehousePalletLocationClient.BatchResult reserved = requireSuccess(call(() -> warehouse.reserve(
internalToken, new WarehousePalletLocationClient.ReserveRequest(resourceTenantId, warehouseId, internalToken, new WarehousePalletLocationClient.ReserveRequest(resourceTenantId, warehouseId,
SOURCE_SERVICE, inboundId, order.getInboundCode(), key, operatorId, volume, SOURCE_SERVICE, inboundId, order.getInboundCode(), key, operatorId, volume,
properties.getDefaultPalletCapacityCbm(), "STANDARD", palletReferences,
LocalDateTime.now().plus(properties.getReservationTtl()), true))), LocalDateTime.now().plus(properties.getReservationTtl()), true))),
"测量完成后库位预占失败"); "测量完成后库位预占失败");
int packageCount = requirePackageCount(order); int packageCount = requirePackageCount(order);
allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId, allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId, packageCount, reserved);
properties.getDefaultPalletCapacityCbm(), packageCount, reserved);
try { try {
WarehousePalletLocationClient.BatchResult occupied = requireSuccess(call(() -> warehouse.confirm( WarehousePalletLocationClient.BatchResult occupied = requireSuccess(call(() -> warehouse.confirm(
internalToken, new WarehousePalletLocationClient.ConfirmRequest(resourceTenantId, warehouseId, internalToken, new WarehousePalletLocationClient.ConfirmRequest(resourceTenantId, warehouseId,
SOURCE_SERVICE, inboundId, operatorId, volume, SOURCE_SERVICE, inboundId, operatorId, volume, packageCount))), "测量完成后库位占用失败");
properties.getDefaultPalletCapacityCbm(), packageCount))), "测量完成后库位占用失败"); allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId, packageCount, occupied);
allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId,
properties.getDefaultPalletCapacityCbm(), packageCount, occupied);
} catch (RuntimeException failure) { } catch (RuntimeException failure) {
try { try {
warehouse.release(internalToken, new WarehousePalletLocationClient.ReleaseRequest( warehouse.release(internalToken, new WarehousePalletLocationClient.ReleaseRequest(
@@ -97,6 +99,24 @@ public class MeasurementLocationOccupationService {
return order.getTotalPackageCount(); return order.getTotalPackageCount();
} }
private java.util.List<WarehousePalletLocationClient.PalletReference> confirmedPalletReferences(
long tenantId, long inboundId) {
java.util.List<WarehousePalletLocationClient.PalletReference> references = measurements
.listWeights(tenantId, inboundId).stream()
.filter(weight -> "CONFIRMED".equals(weight.getRecognitionStatus()))
.filter(weight -> weight.getConfirmedAt() != null && weight.getNetWeightKg() != null)
.filter(weight -> weight.getPalletId() != null && weight.getPalletId() > 0)
.map(weight -> new WarehousePalletLocationClient.PalletReference(weight.getId(), weight.getPalletId()))
.toList();
if (references.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "至少需要一张已确认的称重图片才能提交测量");
}
if (references.size() > 200) {
throw new BizException(ResultCode.VALIDATION_FAILED, "单票最多记录200张已确认称重图片");
}
return references;
}
private static <T> ApiResponse<T> call(RemoteCall<T> call) { private static <T> ApiResponse<T> call(RemoteCall<T> call) {
try { return call.invoke(); } try { return call.invoke(); }
catch (FeignException exception) { catch (FeignException exception) {
@@ -58,12 +58,12 @@ class InboundPalletAllocationRepositoryTest {
when(mapper.selectList(any())).thenReturn(List.of(first, second)); when(mapper.selectList(any())).thenReturn(List.of(first, second));
var result = new WarehousePalletLocationClient.BatchResult(2, List.of( var result = new WarehousePalletLocationClient.BatchResult(2, List.of(
new WarehousePalletLocationClient.AllocationResult(91L, null, 1, 501L, "P1", 11L, "A1", new WarehousePalletLocationClient.AllocationResult(91L, null, 1, 501L, "P1", new BigDecimal("1.2"), 11L, "A1",
new BigDecimal("1.2"), "OCCUPIED"), new BigDecimal("1.2"), "OCCUPIED"),
new WarehousePalletLocationClient.AllocationResult(92L, null, 2, 501L, "P1", 12L, "A2", new WarehousePalletLocationClient.AllocationResult(92L, null, 2, 501L, "P1", new BigDecimal("1.2"), 12L, "A2",
new BigDecimal("1.2"), "OCCUPIED"))); new BigDecimal("1.2"), "OCCUPIED")));
repository.replace(10001L, 31L, 88L, 0L, new BigDecimal("1.2"), 2, result); repository.replace(10001L, 31L, 88L, 0L, 2, result);
ArgumentCaptor<InboundPalletAllocationEntity> inserted = ArgumentCaptor.forClass(InboundPalletAllocationEntity.class); ArgumentCaptor<InboundPalletAllocationEntity> inserted = ArgumentCaptor.forClass(InboundPalletAllocationEntity.class);
verify(mapper, org.mockito.Mockito.times(2)).insert(inserted.capture()); verify(mapper, org.mockito.Mockito.times(2)).insert(inserted.capture());
@@ -115,21 +115,28 @@ class CartonMeasurementServiceTest {
} }
@Test @Test
void measuringInboundAllowsManualEntryBeforeMeasurementSubmission() { void measuringInboundWithoutMeasurementAllowsManualEntryBeforeSubmission() {
InboundOrderEntity measuring = inbound("MEASURING"); InboundOrderEntity measuring = inbound("MEASURING");
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0);
when(repository.findInbound(10001L, 70001L)).thenReturn(measuring); when(repository.findInbound(10001L, 70001L)).thenReturn(measuring);
when(repository.lockInbound(10001L, 70001L)).thenReturn(measuring); when(repository.lockInbound(10001L, 70001L)).thenReturn(measuring);
when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null); when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null);
when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement); when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(null);
when(repository.listItems(10001L, 30001L)).thenAnswer(invocation -> List.of(insertedItem.get())); when(repository.listItems(org.mockito.ArgumentMatchers.eq(10001L),
when(repository.updateMeasurement(measurement)).thenReturn(true); org.mockito.ArgumentMatchers.anyLong())).thenAnswer(invocation -> List.of(insertedItem.get()));
when(repository.updateMeasurement(any())).thenReturn(true);
CartonMeasurementResult result = service.upsertManualForInbound( CartonMeasurementResult result = service.upsertManualForInbound(
10001L, 90001L, 70001L, acceptanceCommand()); 10001L, 90001L, 70001L, acceptanceCommand());
ArgumentCaptor<InboundMeasurementEntity> measurement =
ArgumentCaptor.forClass(InboundMeasurementEntity.class);
verify(repository).insertMeasurement(measurement.capture());
assertThat(result.created()).isTrue(); assertThat(result.created()).isTrue();
assertThat(measurement.getSourceType()).isEqualTo(MeasurementBatchService.SOURCE_MANUAL); assertThat(result.measurementId()).isEqualTo(measurement.getValue().getId());
assertThat(measurement.getValue().getSourceType()).isEqualTo(MeasurementBatchService.SOURCE_MANUAL);
assertThat(measurement.getValue().getSourceCode()).isEqualTo("PLATFORM");
assertThat(measurement.getValue().getStatus()).isEqualTo(MeasurementBatchService.STATUS_RECEIVED);
assertThat(insertedItem.get().getMeasurementId()).isEqualTo(measurement.getValue().getId());
verify(images).upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any()); verify(images).upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any());
} }
@@ -598,6 +598,7 @@ class InboundMeasurementServiceTest {
assertThat(inbound.getMeasureReviewStatus()).isEqualTo("PENDING"); assertThat(inbound.getMeasureReviewStatus()).isEqualTo("PENDING");
assertThat(measurement.getStatus()).isEqualTo("SUBMITTED"); assertThat(measurement.getStatus()).isEqualTo("SUBMITTED");
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("20.000"); assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("20.000");
assertThat(inbound.getPalletCount()).isEqualTo(1);
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("1.2345"); assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("1.2345");
} }
@@ -4,6 +4,8 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.WarehousePalletLocationClient; import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import com.baiyue.firstleg.config.InboundProperties; import com.baiyue.firstleg.config.InboundProperties;
import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository; import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
import com.baiyue.firstleg.repository.InboundWorkflowRepository; import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -39,6 +41,7 @@ class MeasurementLocationOccupationServiceTest {
@MockBean private WarehousePalletLocationClient warehouse; @MockBean private WarehousePalletLocationClient warehouse;
@MockBean private InboundPalletAllocationRepository allocations; @MockBean private InboundPalletAllocationRepository allocations;
@MockBean private InboundWorkflowRepository workflow; @MockBean private InboundWorkflowRepository workflow;
@MockBean private InboundMeasurementRepository measurements;
@Resource private InboundProperties properties; @Resource private InboundProperties properties;
@Resource private MeasurementLocationOccupationService service; @Resource private MeasurementLocationOccupationService service;
@@ -46,6 +49,7 @@ class MeasurementLocationOccupationServiceTest {
void platformWarehouseResourcesUseZeroWhileSnapshotsRemainInBusinessTenant() { void platformWarehouseResourcesUseZeroWhileSnapshotsRemainInBusinessTenant() {
properties.setWarehouseResourceTenantId(90001L); properties.setWarehouseResourceTenantId(90001L);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(confirmedWeights());
when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"), when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED"))); org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED")));
when(warehouse.confirm(org.mockito.ArgumentMatchers.eq("internal-token"), when(warehouse.confirm(org.mockito.ArgumentMatchers.eq("internal-token"),
@@ -59,6 +63,9 @@ class MeasurementLocationOccupationServiceTest {
assertThat(reserve.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT); assertThat(reserve.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT);
assertThat(reserve.getValue().businessId()).isEqualTo(INBOUND); assertThat(reserve.getValue().businessId()).isEqualTo(INBOUND);
assertThat(reserve.getValue().idempotencyKey()).startsWith("measure-88-30-3-"); assertThat(reserve.getValue().idempotencyKey()).startsWith("measure-88-30-3-");
assertThat(reserve.getValue().pallets())
.extracting(WarehousePalletLocationClient.PalletReference::palletId)
.containsExactly(601L, 601L, 601L);
ArgumentCaptor<WarehousePalletLocationClient.ConfirmRequest> confirm = ArgumentCaptor<WarehousePalletLocationClient.ConfirmRequest> confirm =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ConfirmRequest.class); ArgumentCaptor.forClass(WarehousePalletLocationClient.ConfirmRequest.class);
@@ -72,7 +79,6 @@ class MeasurementLocationOccupationServiceTest {
org.mockito.ArgumentMatchers.eq(INBOUND), org.mockito.ArgumentMatchers.eq(INBOUND),
org.mockito.ArgumentMatchers.eq(WAREHOUSE), org.mockito.ArgumentMatchers.eq(WAREHOUSE),
org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT), org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT),
org.mockito.ArgumentMatchers.eq(new BigDecimal("1.200000")),
org.mockito.ArgumentMatchers.eq(20), org.mockito.ArgumentMatchers.eq(20),
org.mockito.ArgumentMatchers.any()); org.mockito.ArgumentMatchers.any());
} }
@@ -81,6 +87,7 @@ class MeasurementLocationOccupationServiceTest {
void defaultResourceTenantRemainsPlatformZero() { void defaultResourceTenantRemainsPlatformZero() {
properties.setWarehouseResourceTenantId(0L); properties.setWarehouseResourceTenantId(0L);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(confirmedWeights());
when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"), when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED"))); org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED")));
when(warehouse.confirm(org.mockito.ArgumentMatchers.eq("internal-token"), when(warehouse.confirm(org.mockito.ArgumentMatchers.eq("internal-token"),
@@ -104,7 +111,6 @@ class MeasurementLocationOccupationServiceTest {
org.mockito.ArgumentMatchers.eq(INBOUND), org.mockito.ArgumentMatchers.eq(INBOUND),
org.mockito.ArgumentMatchers.eq(WAREHOUSE), org.mockito.ArgumentMatchers.eq(WAREHOUSE),
org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT), org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT),
org.mockito.ArgumentMatchers.eq(new BigDecimal("1.200000")),
org.mockito.ArgumentMatchers.eq(20), org.mockito.ArgumentMatchers.eq(20),
org.mockito.ArgumentMatchers.any()); org.mockito.ArgumentMatchers.any());
} }
@@ -113,6 +119,7 @@ class MeasurementLocationOccupationServiceTest {
void confirmFailureReleasesReservationInResourceTenantAndKeepsOriginalFailure() { void confirmFailureReleasesReservationInResourceTenantAndKeepsOriginalFailure() {
properties.setWarehouseResourceTenantId(90001L); properties.setWarehouseResourceTenantId(90001L);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(confirmedWeights());
when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"), when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED"))); org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED")));
IllegalStateException failure = new IllegalStateException("confirm failed"); IllegalStateException failure = new IllegalStateException("confirm failed");
@@ -130,7 +137,7 @@ class MeasurementLocationOccupationServiceTest {
assertThat(release.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT); assertThat(release.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT);
assertThat(release.getValue().businessId()).isEqualTo(INBOUND); assertThat(release.getValue().businessId()).isEqualTo(INBOUND);
verify(allocations).replace(BUSINESS_TENANT, INBOUND, WAREHOUSE, PLATFORM_RESOURCE_TENANT, verify(allocations).replace(BUSINESS_TENANT, INBOUND, WAREHOUSE, PLATFORM_RESOURCE_TENANT,
new BigDecimal("1.200000"), 20, batch("RESERVED")); 20, batch("RESERVED"));
} }
private static InboundProperties properties() { private static InboundProperties properties() {
@@ -159,9 +166,22 @@ class MeasurementLocationOccupationServiceTest {
} }
private static WarehousePalletLocationClient.BatchResult batch(String state) { private static WarehousePalletLocationClient.BatchResult batch(String state) {
return new WarehousePalletLocationClient.BatchResult(1, List.of( return new WarehousePalletLocationClient.BatchResult(3, java.util.stream.IntStream.rangeClosed(1, 3)
new WarehousePalletLocationClient.AllocationResult( .mapToObj(index -> new WarehousePalletLocationClient.AllocationResult(
501L, "RES-001", 1, 601L, "PL-001", 701L, "A-001", 500L + index, "RES-00" + index, index, 601L, "PL-001", new BigDecimal("1.200000"),
new BigDecimal("1.0000"), state))); 700L + index, "A-00" + index, new BigDecimal("0.333333"), state))
.toList());
}
private static List<InboundWeightResultEntity> confirmedWeights() {
return java.util.stream.LongStream.rangeClosed(1, 3).mapToObj(index -> {
InboundWeightResultEntity weight = new InboundWeightResultEntity();
weight.setId(800L + index);
weight.setPalletId(601L);
weight.setRecognitionStatus("CONFIRMED");
weight.setConfirmedAt(java.time.LocalDateTime.now());
weight.setNetWeightKg(new BigDecimal("10"));
return weight;
}).toList();
} }
} }
@@ -104,7 +104,7 @@ public final class LocationDtos {
public record OccupancyTarget( public record OccupancyTarget(
@Min(1) long reservationId, @Min(1) long reservationId,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal allocatedVolumeCbm, @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, @NotBlank @Size(max = 128) String idempotencyKey,
@Min(1) long operatorId, @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 totalVolumeCbm,
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6) BigDecimal palletCapacityCbm, @NotNull @Size(min = 1, max = 200) List<@NotNull @jakarta.validation.Valid PalletReference> pallets,
@Size(max = 32) String palletType,
@NotNull LocalDateTime expiresAt, @NotNull LocalDateTime expiresAt,
@NotNull Boolean measurementCompleted @NotNull Boolean measurementCompleted
) { ) {
} }
/** 一张已确认称重图片对应一托;同一托盘主数据允许重复引用。 */
public record PalletReference(
@Min(1) long weightResultId,
@Min(1) long palletId
) {
}
public record ConfirmRequest( public record ConfirmRequest(
@Min(0) long tenantId, @Min(0) long tenantId,
@Min(1) long warehouseId, @Min(1) long warehouseId,
@@ -39,7 +45,6 @@ public final class PalletAllocationDtos {
@Min(1) long businessId, @Min(1) long businessId,
@Min(1) long operatorId, @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 totalVolumeCbm,
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6) BigDecimal palletCapacityCbm,
@Min(1) int packageCount @Min(1) int packageCount
) { ) {
} }
@@ -193,6 +198,7 @@ public final class PalletAllocationDtos {
int allocationNo, int allocationNo,
long palletId, long palletId,
String palletCode, String palletCode,
BigDecimal palletCapacityCbm,
long locationId, long locationId,
String locationCode, String locationCode,
BigDecimal allocatedVolumeCbm, BigDecimal allocatedVolumeCbm,
@@ -62,16 +62,6 @@ public class PalletRepository {
.ne(excludedId != null, PalletEntity::getId, excludedId)) > 0; .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 @Service
public class PalletLocationAllocationService { public class PalletLocationAllocationService {
/** 测量完成后默认推荐的普通托盘类型编码。 */
public static final String DEFAULT_PALLET_TYPE = "STANDARD";
/** 仓库、托盘和库位属于平台公共资源域。 */ /** 仓库、托盘和库位属于平台公共资源域。 */
private static final long PLATFORM_RESOURCE_TENANT_ID = 0L; private static final long PLATFORM_RESOURCE_TENANT_ID = 0L;
/** 仅头程服务可结算头程入库单占用的仓储资源。 */ /** 仅头程服务可结算头程入库单占用的仓储资源。 */
@@ -41,8 +39,6 @@ public class PalletLocationAllocationService {
@Resource @Resource
private PalletRepository palletRepository; private PalletRepository palletRepository;
@Resource @Resource
private PalletVolumeSplitter splitter;
@Resource
private LocationResourceService locationResourceService; private LocationResourceService locationResourceService;
@Resource @Resource
private PalletLocationTransferOperationRepository transferOperations; private PalletLocationTransferOperationRepository transferOperations;
@@ -59,74 +55,78 @@ public class PalletLocationAllocationService {
if (!Boolean.TRUE.equals(request.measurementCompleted())) { if (!Boolean.TRUE.equals(request.measurementCompleted())) {
throw new BizException(ResultCode.CONFLICT, "只能使用已完成测量的总体积拆分托盘并占用库位"); throw new BizException(ResultCode.CONFLICT, "只能使用已完成测量的总体积拆分托盘并占用库位");
} }
var split = splitter.split(request.totalVolumeCbm(), request.palletCapacityCbm());
List<LocationDtos.ActiveResource> existing = active(request.tenantId(), request.sourceService(), request.businessId()); List<LocationDtos.ActiveResource> existing = active(request.tenantId(), request.sourceService(), request.businessId());
if (!existing.isEmpty()) { 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()) List<PalletEntity> pallets = new ArrayList<>(request.pallets().size());
? request.palletType() : DEFAULT_PALLET_TYPE; for (PalletAllocationDtos.PalletReference reference : request.pallets()) {
List<PalletEntity> pallets = palletRepository.findReusable(request.tenantId(), request.warehouseId(), PalletEntity pallet = palletRepository.findOwned(request.tenantId(), request.warehouseId(), reference.palletId());
palletType, request.palletCapacityCbm()); if (pallet == null) {
if (pallets.isEmpty()) { throw new BizException(ResultCode.NOT_FOUND, "称重图片关联的托盘主数据不存在或不属于当前仓库");
throw new BizException(ResultCode.NOT_FOUND, "未配置匹配类型和容量的托盘主数据"); }
pallets.add(pallet);
} }
List<LocationDtos.ReservationTarget> targets = new ArrayList<>(split.palletCount()); List<LocationDtos.ReservationTarget> targets = new ArrayList<>(pallets.size());
for (int index = 0; index < split.palletCount(); index++) { for (int index = 0; index < pallets.size(); index++) {
PalletEntity pallet = pallets.get(index % pallets.size()); PalletEntity pallet = pallets.get(index);
targets.add(new LocationDtos.ReservationTarget(pallet.getId(), pallet.getPalletCode(), 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( List<LocationDtos.ReservationResult> reservations = locationResourceService.reserveAvailable(
new LocationDtos.ReserveCommand(request.tenantId(), request.warehouseId(), request.sourceService(), new LocationDtos.ReserveCommand(request.tenantId(), request.warehouseId(), request.sourceService(),
request.businessId(), request.inboundCode(), request.operatorId(), request.expiresAt(), targets)); request.businessId(), request.inboundCode(), request.operatorId(), request.expiresAt(), targets));
if (reservations.size() != split.palletCount()) { if (reservations.size() != pallets.size()) {
throw new BizException(ResultCode.CONFLICT, "库位预占结果数量不一致"); throw new BizException(ResultCode.CONFLICT, "库位预占结果数量与已确认称重图片数量不一致");
} }
List<PalletAllocationDtos.AllocationResult> results = new ArrayList<>(reservations.size()); List<PalletAllocationDtos.AllocationResult> results = new ArrayList<>(reservations.size());
for (int index = 0; index < reservations.size(); index++) { for (int index = 0; index < reservations.size(); index++) {
var item = reservations.get(index); var item = reservations.get(index);
results.add(new PalletAllocationDtos.AllocationResult(item.reservationId(), item.reservationNo(), index + 1, results.add(new PalletAllocationDtos.AllocationResult(item.reservationId(), item.reservationNo(), index + 1,
item.palletId(), item.palletCode(), item.locationId(), item.locationCode(), item.palletId(), item.palletCode(), pallets.get(index).getCapacityCbm(),
split.allocations().get(index).allocatedVolumeCbm(), "RESERVED")); 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 @Transactional
@BusinessAudit(event = "PALLET_LOCATION_CONFIRM", resourceType = "INBOUND", resourceId = "#request.businessId", description = "确认库位占用") @BusinessAudit(event = "PALLET_LOCATION_CONFIRM", resourceType = "INBOUND", resourceId = "#request.businessId", description = "确认库位占用")
public PalletAllocationDtos.BatchResult confirm(PalletAllocationDtos.ConfirmRequest request) { 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()); List<LocationDtos.ActiveResource> active = active(request.tenantId(), request.sourceService(), request.businessId());
if (active.size() != split.palletCount()) { if (active.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "活动预占数量与托盘拆分数量不一致"); throw new BizException(ResultCode.CONFLICT, "入库单没有可确认的活动库位预占");
}
if (active.size() > request.packageCount()) {
throw new BizException(ResultCode.CONFLICT, "托盘数量超过入库单总箱数,无法为每个托盘分配至少一箱");
} }
active.sort(Comparator.comparingLong(LocationDtos.ActiveResource::reservationId)); active.sort(Comparator.comparingLong(LocationDtos.ActiveResource::reservationId));
List<LocationDtos.OccupancyTarget> targets = new ArrayList<>(active.size()); List<LocationDtos.OccupancyTarget> targets = new ArrayList<>(active.size());
int basePackageCount = request.packageCount() / 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++) { for (int index = 0; index < active.size(); index++) {
int allocatedPackages = basePackageCount + (index < remainder ? 1 : 0);
targets.add(new LocationDtos.OccupancyTarget(active.get(index).reservationId(), 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( List<LocationDtos.OccupancyResult> occupied = locationResourceService.confirmOccupied(
new LocationDtos.ConfirmCommand(request.tenantId(), request.warehouseId(), request.sourceService(), new LocationDtos.ConfirmCommand(request.tenantId(), request.warehouseId(), request.sourceService(),
request.businessId(), request.operatorId(), targets)); request.businessId(), request.operatorId(), targets));
if (occupied.size() != active.size()) {
throw new BizException(ResultCode.CONFLICT, "库位占用结果数量与已确认称重图片数量不一致");
}
List<PalletAllocationDtos.AllocationResult> results = new ArrayList<>(occupied.size()); List<PalletAllocationDtos.AllocationResult> results = new ArrayList<>(occupied.size());
for (int index = 0; index < occupied.size(); index++) { for (int index = 0; index < occupied.size(); index++) {
var item = occupied.get(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, results.add(new PalletAllocationDtos.AllocationResult(item.reservationId(), active.get(index).reservationNo(), index + 1,
item.palletId(), active.get(index).palletCode(), item.locationId(), item.locationCode(), item.palletId(), active.get(index).palletCode(), pallet.getCapacityCbm(), item.locationId(), item.locationCode(),
split.allocations().get(index).allocatedVolumeCbm(), "OCCUPIED")); 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 @Transactional
@@ -528,19 +528,27 @@ public class PalletLocationAllocationService {
return locationResourceService.findOccupiedResources(tenantId, sourceService, businessId); return locationResourceService.findOccupiedResources(tenantId, sourceService, businessId);
} }
private static PalletAllocationDtos.BatchResult fromActive(List<LocationDtos.ActiveResource> active, private PalletAllocationDtos.BatchResult fromActive(long tenantId, long warehouseId,
PalletVolumeSplitter.SplitResult split) { List<LocationDtos.ActiveResource> active,
if (active.size() != split.palletCount()) { java.math.BigDecimal totalVolumeCbm) {
throw new BizException(ResultCode.CONFLICT, "幂等请求与已有托盘拆分结果不一致");
}
active.sort(Comparator.comparingLong(LocationDtos.ActiveResource::reservationId)); active.sort(Comparator.comparingLong(LocationDtos.ActiveResource::reservationId));
List<PalletAllocationDtos.AllocationResult> results = new ArrayList<>(active.size()); List<PalletAllocationDtos.AllocationResult> results = new ArrayList<>(active.size());
for (int index = 0; index < active.size(); index++) { for (int index = 0; index < active.size(); index++) {
var item = active.get(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, results.add(new PalletAllocationDtos.AllocationResult(item.reservationId(), item.reservationNo(), index + 1,
item.palletId(), item.palletCode(), item.locationId(), item.locationCode(), item.palletId(), item.palletCode(), pallet.getCapacityCbm(), item.locationId(), item.locationCode(),
split.allocations().get(index).allocatedVolumeCbm(), item.state())); 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), color=VALUES(color),
remark=VALUES(remark); 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( INSERT INTO wh_location(
id,tenant_id,warehouse_id,zone_code,zone_name,location_code,location_name, 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 location_type,max_weight_kg,shared_overflow,status,remark,created_by,updated_by
) )
VALUES 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), (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), (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) (190000000000000204,0,190000000000000002,'A','A区','A0','A0 共享溢出位','PALLET',NULL,1,'AVAILABLE','系统共享临时溢出位',0,0)
@@ -39,3 +81,32 @@ ON DUPLICATE KEY UPDATE
status='AVAILABLE', status='AVAILABLE',
remark=VALUES(remark), remark=VALUES(remark),
deleted=0; 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;
@@ -18,9 +18,10 @@ class PalletAllocationDtosValidationTest {
LocalDateTime expiresAt = LocalDateTime.now().plusMinutes(30); LocalDateTime expiresAt = LocalDateTime.now().plusMinutes(30);
assertThat(validator.validate(new PalletAllocationDtos.ReserveRequest(0L, 20L, "FIRSTLEG", 88L, assertThat(validator.validate(new PalletAllocationDtos.ReserveRequest(0L, 20L, "FIRSTLEG", 88L,
"RK-001", "measure-88", 9L, BigDecimal.ONE, BigDecimal.ONE, "STANDARD", expiresAt, true))).isEmpty(); "RK-001", "measure-88", 9L, BigDecimal.ONE,
List.of(new PalletAllocationDtos.PalletReference(7001L, 30L)), expiresAt, true))).isEmpty();
assertThat(validator.validate(new PalletAllocationDtos.ConfirmRequest(0L, 20L, "FIRSTLEG", 88L, assertThat(validator.validate(new PalletAllocationDtos.ConfirmRequest(0L, 20L, "FIRSTLEG", 88L,
9L, BigDecimal.ONE, BigDecimal.ONE, 20))).isEmpty(); 9L, BigDecimal.ONE, 20))).isEmpty();
assertThat(validator.validate(new PalletAllocationDtos.ReleaseRequest(0L, "FIRSTLEG", 88L, 9L, assertThat(validator.validate(new PalletAllocationDtos.ReleaseRequest(0L, "FIRSTLEG", 88L, 9L,
"入库补偿"))).isEmpty(); "入库补偿"))).isEmpty();
assertThat(validator.validate(new PalletAllocationDtos.SelectiveReleaseRequest(0L, "FIRSTLEG", 88L, assertThat(validator.validate(new PalletAllocationDtos.SelectiveReleaseRequest(0L, "FIRSTLEG", 88L,
@@ -10,7 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class InitialWarehouseResourceSeedSqlContractTest { class InitialWarehouseResourceSeedSqlContractTest {
@Test @Test
void initializesReusablePalletMasterDataAndAvailableLocationsForBothDomesticWarehouses() throws Exception { void initializesReusablePalletsAndYiwuLocationGrid() throws Exception {
assertModuleSeed(Files.readString(Path.of("src", "main", "resources", "db", "phase2-pallet-seed.sql"), assertModuleSeed(Files.readString(Path.of("src", "main", "resources", "db", "phase2-pallet-seed.sql"),
StandardCharsets.UTF_8)); StandardCharsets.UTF_8));
assertUnifiedSeed(Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), assertUnifiedSeed(Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
@@ -30,7 +30,15 @@ class InitialWarehouseResourceSeedSqlContractTest {
assertThat(sql) assertThat(sql)
.contains("0," + yiwuId + ",'TP-001'") .contains("0," + yiwuId + ",'TP-001'")
.contains("0," + dongguanId + ",'TP-002'") .contains("0," + dongguanId + ",'TP-002'")
.contains("(" + idPrefix + "201,0," + yiwuId + ",'A','A区','A1'") .contains("WHEN zones.zone_index = 0 AND slots.slot_number = 1 THEN 201")
.contains("ELSE 203 + zones.zone_index * 15 + slots.slot_number")
.contains("SELECT zone_index + 1 FROM zones WHERE zone_index < 5")
.contains("SELECT slot_number + 1 FROM slots WHERE slot_number < 15")
.contains("CONCAT(CHAR(65 + zones.zone_index), slots.slot_number)")
.contains("CROSS JOIN slots")
.contains("location_code REGEXP '^[A-F]([1-9]|1[0-5])$'")
.contains("<> 90 THEN")
.contains("义乌仓 A-F 每区 15 个普通库位初始化不完整")
.contains("(" + idPrefix + "202,0," + yiwuId + ",'A','A区','A0'") .contains("(" + idPrefix + "202,0," + yiwuId + ",'A','A区','A0'")
.contains("(" + idPrefix + "203,0," + dongguanId + ",'A','A区','A1'") .contains("(" + idPrefix + "203,0," + dongguanId + ",'A','A区','A1'")
.contains("(" + idPrefix + "204,0," + dongguanId + ",'A','A区','A0'") .contains("(" + idPrefix + "204,0," + dongguanId + ",'A','A区','A0'")
@@ -47,50 +47,48 @@ class PalletLocationAllocationServiceTest {
@Configuration(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
static class TestConfig { static class TestConfig {
@Bean PalletVolumeSplitter splitter() { return new PalletVolumeSplitter(); }
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); } @Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
} }
@Test @Test
void reusesOnePalletMasterForMultipleAllocations() { void oneConfirmedWeightImageCreatesOneAllocationWithoutCapacityMatching() {
when(locations.findActiveResources(10001, "FIRSTLEG", 88)).thenReturn(List.of()); when(locations.findActiveResources(10001, "FIRSTLEG", 88)).thenReturn(List.of());
when(pallets.findReusable(10001, 20, "木托盘", new BigDecimal("2"))) when(pallets.findOwned(10001, 20, 30)).thenReturn(pallet(30, "TP-001"));
.thenReturn(List.of(pallet(30, "TP-001")));
when(locations.reserveAvailable(any())).thenReturn(List.of( when(locations.reserveAvailable(any())).thenReturn(List.of(
new LocationDtos.ReservationResult(101, "LR101", 30, "TP-001", 40, "A-01", false, new LocationDtos.ReservationResult(101, "LR101", 30, "TP-001", 40, "A-01", false,
LocalDateTime.now().plusMinutes(30)),
new LocationDtos.ReservationResult(102, "LR102", 30, "TP-001", 41, "A-02", false,
LocalDateTime.now().plusMinutes(30)))); LocalDateTime.now().plusMinutes(30))));
var request = new PalletAllocationDtos.ReserveRequest(10001, 20, "FIRSTLEG", 88, "RK-001", "measure-001", var request = new PalletAllocationDtos.ReserveRequest(10001, 20, "FIRSTLEG", 88, "RK-001", "measure-001",
9001, new BigDecimal("4"), new BigDecimal("2"), "木托盘", LocalDateTime.now().plusMinutes(30), true); 9001, new BigDecimal("4"), List.of(new PalletAllocationDtos.PalletReference(7001, 30)),
assertThat(service.reserve(request).allocations()).hasSize(2) LocalDateTime.now().plusMinutes(30), true);
.extracting(PalletAllocationDtos.AllocationResult::palletId) assertThat(service.reserve(request).allocations()).singleElement().satisfies(item -> {
.containsOnly(30L); assertThat(item.palletId()).isEqualTo(30L);
assertThat(item.allocatedVolumeCbm()).isEqualByComparingTo("4");
assertThat(item.palletCapacityCbm()).isEqualByComparingTo("1.2");
});
org.mockito.ArgumentCaptor<LocationDtos.ReserveCommand> command = org.mockito.ArgumentCaptor<LocationDtos.ReserveCommand> command =
org.mockito.ArgumentCaptor.forClass(LocationDtos.ReserveCommand.class); org.mockito.ArgumentCaptor.forClass(LocationDtos.ReserveCommand.class);
verify(locations).reserveAvailable(command.capture()); verify(locations).reserveAvailable(command.capture());
assertThat(command.getValue().targets()).hasSize(2) assertThat(command.getValue().targets()).singleElement()
.extracting(LocationDtos.ReservationTarget::palletId) .extracting(LocationDtos.ReservationTarget::palletId).isEqualTo(30L);
.containsOnly(30L);
} }
@Test @Test
void shouldHandleCase02() { void shouldHandleCase02() {
when(locations.findActiveResources(10001, "FIRSTLEG", 99)).thenReturn(List.of()); when(locations.findActiveResources(10001, "FIRSTLEG", 99)).thenReturn(List.of());
when(pallets.findReusable(10001, 20, "STANDARD", new BigDecimal("2"))) when(pallets.findOwned(10001, 20, 30)).thenReturn(pallet(30, "TP-001"));
.thenReturn(List.of(pallet(30, "TP-001")));
when(locations.reserveAvailable(any())).thenReturn(List.of( when(locations.reserveAvailable(any())).thenReturn(List.of(
new LocationDtos.ReservationResult(101, "LR101", 30, "TP-001", 40, "A0", true, new LocationDtos.ReservationResult(101, "LR101", 30, "TP-001", 40, "A0", true,
LocalDateTime.now().plusMinutes(30)))); LocalDateTime.now().plusMinutes(30))));
var request = new PalletAllocationDtos.ReserveRequest(10001, 20, "FIRSTLEG", 99, "RK-001", "measure-99", var request = new PalletAllocationDtos.ReserveRequest(10001, 20, "FIRSTLEG", 99, "RK-001", "measure-99",
9001, new BigDecimal("2"), new BigDecimal("2"), null, LocalDateTime.now().plusMinutes(30), true); 9001, new BigDecimal("2"), List.of(new PalletAllocationDtos.PalletReference(7001, 30)),
LocalDateTime.now().plusMinutes(30), true);
var result = service.reserve(request); var result = service.reserve(request);
assertThat(result.allocations().get(0).locationCode()).isEqualTo("A0"); assertThat(result.allocations().get(0).locationCode()).isEqualTo("A0");
verify(pallets).findReusable(10001, 20, "STANDARD", new BigDecimal("2")); verify(pallets).findOwned(10001, 20, 30);
} }
@Test @Test
@@ -129,7 +127,7 @@ class PalletLocationAllocationServiceTest {
assertThat(result.palletCount()).isEqualTo(1); assertThat(result.palletCount()).isEqualTo(1);
assertThat(result.allocations().get(0).state()).isEqualTo("RESERVED"); assertThat(result.allocations().get(0).state()).isEqualTo("RESERVED");
verify(pallets, never()).findReusable(any(Long.class), any(Long.class), any(), any()); verify(pallets, never()).findOwned(anyLong(), anyLong(), anyLong());
} }
@Test @Test
@@ -139,9 +137,10 @@ class PalletLocationAllocationServiceTest {
when(locations.findActiveResources(10001L, "FIRSTLEG", 88L)).thenReturn(List.of(resource)); when(locations.findActiveResources(10001L, "FIRSTLEG", 88L)).thenReturn(List.of(resource));
when(locations.confirmOccupied(any())).thenReturn(List.of(new LocationDtos.OccupancyResult( when(locations.confirmOccupied(any())).thenReturn(List.of(new LocationDtos.OccupancyResult(
301L, "OC-301", 101L, 30L, 40L, "A-01", false))); 301L, "OC-301", 101L, 30L, 40L, "A-01", false)));
when(pallets.findOwnedIncludingDeleted(10001L, 20L, 30L)).thenReturn(pallet(30L, "TP-001"));
service.confirm(new PalletAllocationDtos.ConfirmRequest(10001L, 20L, "FIRSTLEG", 88L, service.confirm(new PalletAllocationDtos.ConfirmRequest(10001L, 20L, "FIRSTLEG", 88L,
9001L, new BigDecimal("1.0"), new BigDecimal("2.0"), 20)); 9001L, new BigDecimal("1.0"), 20));
org.mockito.ArgumentCaptor<LocationDtos.ConfirmCommand> command = org.mockito.ArgumentCaptor<LocationDtos.ConfirmCommand> command =
org.mockito.ArgumentCaptor.forClass(LocationDtos.ConfirmCommand.class); org.mockito.ArgumentCaptor.forClass(LocationDtos.ConfirmCommand.class);
@@ -152,10 +151,10 @@ class PalletLocationAllocationServiceTest {
} }
@Test @Test
void confirmDistributesRemainderWithoutZeroPackageAllocations() { void confirmedImageCountDefinesPalletCountEvenWhenItExceedsPackageCount() {
List<LocationDtos.ActiveResource> resources = new ArrayList<>(); List<LocationDtos.ActiveResource> resources = new ArrayList<>();
List<LocationDtos.OccupancyResult> occupancyResults = new ArrayList<>(); List<LocationDtos.OccupancyResult> occupancyResults = new ArrayList<>();
for (int index = 1; index <= 10; index++) { for (int index = 1; index <= 3; index++) {
long reservationId = 100L + index; long reservationId = 100L + index;
long palletId = 200L + index; long palletId = 200L + index;
long locationId = 300L + index; long locationId = 300L + index;
@@ -163,33 +162,21 @@ class PalletLocationAllocationServiceTest {
"TP-" + index, locationId, "A-" + index, false, "RESERVED")); "TP-" + index, locationId, "A-" + index, false, "RESERVED"));
occupancyResults.add(new LocationDtos.OccupancyResult(400L + index, "OC-" + index, occupancyResults.add(new LocationDtos.OccupancyResult(400L + index, "OC-" + index,
reservationId, palletId, locationId, "A-" + index, false)); reservationId, palletId, locationId, "A-" + index, false));
PalletEntity pallet = pallet(palletId, "TP-" + index);
when(pallets.findOwnedIncludingDeleted(10001L, 20L, palletId)).thenReturn(pallet);
} }
when(locations.findActiveResources(10001L, "FIRSTLEG", 89L)).thenReturn(resources); when(locations.findActiveResources(10001L, "FIRSTLEG", 89L)).thenReturn(resources);
when(locations.confirmOccupied(any())).thenReturn(occupancyResults); when(locations.confirmOccupied(any())).thenReturn(occupancyResults);
service.confirm(new PalletAllocationDtos.ConfirmRequest(10001L, 20L, "FIRSTLEG", 89L, service.confirm(new PalletAllocationDtos.ConfirmRequest(10001L, 20L, "FIRSTLEG", 89L,
9001L, new BigDecimal("12.0"), new BigDecimal("1.2"), 12)); 9001L, new BigDecimal("12.0"), 2));
org.mockito.ArgumentCaptor<LocationDtos.ConfirmCommand> command = org.mockito.ArgumentCaptor<LocationDtos.ConfirmCommand> command =
org.mockito.ArgumentCaptor.forClass(LocationDtos.ConfirmCommand.class); org.mockito.ArgumentCaptor.forClass(LocationDtos.ConfirmCommand.class);
verify(locations).confirmOccupied(command.capture()); verify(locations).confirmOccupied(command.capture());
assertThat(command.getValue().targets()) assertThat(command.getValue().targets())
.extracting(LocationDtos.OccupancyTarget::allocatedPackageCount) .extracting(LocationDtos.OccupancyTarget::allocatedPackageCount)
.containsExactly(2, 2, 1, 1, 1, 1, 1, 1, 1, 1); .containsExactly(1, 1, 0);
}
@Test
void confirmRejectsMorePalletsThanPackages() {
List<LocationDtos.ActiveResource> resources = List.of(
new LocationDtos.ActiveResource(101L, null, 20L, 30L, "TP-001", 40L, "A-01", false, "RESERVED"),
new LocationDtos.ActiveResource(102L, null, 20L, 31L, "TP-002", 41L, "A-02", false, "RESERVED"));
when(locations.findActiveResources(10001L, "FIRSTLEG", 90L)).thenReturn(resources);
assertThatThrownBy(() -> service.confirm(new PalletAllocationDtos.ConfirmRequest(10001L, 20L,
"FIRSTLEG", 90L, 9001L, new BigDecimal("2.0"), new BigDecimal("1.0"), 1)))
.isInstanceOf(BizException.class)
.hasMessageContaining("托盘数量超过入库单总箱数");
verify(locations, never()).confirmOccupied(any());
} }
@Test @Test
@@ -490,13 +477,15 @@ class PalletLocationAllocationServiceTest {
private static PalletAllocationDtos.ReserveRequest reserve(long businessId) { private static PalletAllocationDtos.ReserveRequest reserve(long businessId) {
return new PalletAllocationDtos.ReserveRequest(10001, 20, "FIRSTLEG", businessId, "RK-001", "measure-001", return new PalletAllocationDtos.ReserveRequest(10001, 20, "FIRSTLEG", businessId, "RK-001", "measure-001",
9001, new BigDecimal("2"), new BigDecimal("2"), "木托盘", LocalDateTime.now().plusMinutes(30), true); 9001, new BigDecimal("2"), List.of(new PalletAllocationDtos.PalletReference(7001, 30)),
LocalDateTime.now().plusMinutes(30), true);
} }
private static PalletEntity pallet(long id, String code) { private static PalletEntity pallet(long id, String code) {
PalletEntity entity = new PalletEntity(); PalletEntity entity = new PalletEntity();
entity.setId(id); entity.setId(id);
entity.setPalletCode(code); entity.setPalletCode(code);
entity.setCapacityCbm(new BigDecimal("1.2"));
return entity; return entity;
} }
} }
@@ -1,37 +0,0 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PalletVolumeSplitterTest {
private final PalletVolumeSplitter splitter = new PalletVolumeSplitter();
@Test
void shouldHandleCase01() {
var result = splitter.split(new BigDecimal("20"), new BigDecimal("2"));
assertThat(result.palletCount()).isEqualTo(10);
assertThat(result.allocations()).allMatch(item -> item.allocatedVolumeCbm().compareTo(new BigDecimal("2")) == 0);
}
@Test
void shouldHandleCase02() {
var result = splitter.split(new BigDecimal("20.1"), new BigDecimal("2"));
assertThat(result.palletCount()).isEqualTo(11);
assertThat(result.allocations().get(10).allocatedVolumeCbm()).isEqualByComparingTo("0.1");
assertThat(result.allocations()).extracting(PalletVolumeSplitter.Allocation::allocationNo)
.containsExactly(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11);
}
@Test
void shouldHandleCase03() {
assertThatThrownBy(() -> splitter.split(null, BigDecimal.ONE)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> splitter.split(BigDecimal.ZERO, BigDecimal.ONE)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> splitter.split(BigDecimal.ONE, BigDecimal.ZERO)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> splitter.split(new BigDecimal("10001"), BigDecimal.ONE)).isInstanceOf(BizException.class);
}
}