This commit is contained in:
2026-08-17 12:31:24 +08:00
parent 8566c6ea9e
commit ac6cf9c904
23 changed files with 396 additions and 58 deletions
@@ -41,7 +41,8 @@ public class InternalPalletLocationController {
}
@PostMapping("/reservations")
@Operation(summary = "按托盘主数据拆分并预占库位")
@Operation(summary = "按已确认称重图片逐条预占库位",
description = "一张图片对应一条库位预占;托盘仅作为可重复引用的主数据,不按容量拆分或限制")
public ApiResponse<PalletAllocationDtos.BatchResult> reserve(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody PalletAllocationDtos.ReserveRequest request) {
verify(token);
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.LocationDtos;
import com.baiyue.warehouse.dto.PalletAllocationDtos;
import com.baiyue.warehouse.dto.LocationDtos.ActiveResource;
import com.baiyue.warehouse.dto.LocationDtos.ConfirmCommand;
import com.baiyue.warehouse.dto.LocationDtos.ExactSelectiveReleaseCommand;
@@ -284,7 +285,7 @@ public class LocationResourceService {
continue;
}
Integer allocatedPackageCount = occupancy.getAllocatedPackageCount();
if (allocatedPackageCount == null || allocatedPackageCount < 1) {
if (allocatedPackageCount == null || allocatedPackageCount < 0) {
throw new BizException(ResultCode.CONFLICT,
"Occupied location has no valid allocated package count and cannot be released exactly");
}
@@ -322,7 +323,7 @@ public class LocationResourceService {
if (!requested.contains(occupancy.getReservationId())) continue;
Integer allocated = occupancy.getAllocatedPackageCount();
LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId());
if (allocated == null || allocated < 1 || location == null || repository.releaseOccupancy(occupancy, command.reason()) != 1
if (allocated == null || allocated < 0 || location == null || repository.releaseOccupancy(occupancy, command.reason()) != 1
|| (!Boolean.TRUE.equals(occupancy.getSharedOverflow()) && repository.changeStatus(command.tenantId(),
occupancy.getWarehouseId(), occupancy.getLocationId(), "OCCUPIED", "AVAILABLE", location.getVersion(),
command.operatorId()) != 1)) {
@@ -567,7 +568,7 @@ public class LocationResourceService {
private static int requireAllocatedPackageCount(LocationOccupancyEntity occupancy) {
Integer allocated = occupancy.getAllocatedPackageCount();
if (allocated == null || allocated < 1) {
if (allocated == null || allocated < 0) {
throw new BizException(ResultCode.CONFLICT, "已占用资源缺少有效分配箱数");
}
return allocated;
@@ -593,6 +594,33 @@ public class LocationResourceService {
return List.copyOf(result);
}
/** 活动分配重放必须与原始称重图片幂等键及托盘引用完全一致。 */
@Transactional(readOnly = true)
public void requireSameActiveReservations(long tenantId, long warehouseId, String sourceService,
long businessId, String requestIdempotencyKey,
List<PalletAllocationDtos.PalletReference> references) {
Map<String, Long> expected = references.stream().collect(Collectors.toMap(
item -> requestIdempotencyKey + ":" + item.weightResultId(),
PalletAllocationDtos.PalletReference::palletId,
(first, duplicate) -> {
throw new BizException(ResultCode.BAD_REQUEST, "同一张称重图片不能重复预占库位");
}));
List<LocationReservationEntity> active = repository.findReservationsByBusiness(
tenantId, sourceService, businessId).stream()
.filter(item -> "RESERVED".equals(item.getReservationStatus())
|| "CONFIRMED".equals(item.getReservationStatus()))
.toList();
Map<String, Long> actual = active.stream().collect(Collectors.toMap(
LocationReservationEntity::getIdempotencyKey, LocationReservationEntity::getPalletId,
(first, duplicate) -> {
throw new BizException(ResultCode.CONFLICT, "活动库位分配存在重复幂等键");
}));
boolean sameWarehouse = active.stream().allMatch(item -> item.getWarehouseId() == warehouseId);
if (!sameWarehouse || !actual.equals(expected)) {
throw new BizException(ResultCode.CONFLICT, "活动库位分配与本次称重图片引用不一致");
}
}
private LocationReservationEntity reservation(ReserveCommand command, ReservationTarget target,
LocationEntity location) {
long id = IdWorker.getId();
@@ -53,10 +53,12 @@ public class PalletLocationAllocationService {
@BusinessAudit(event = "PALLET_LOCATION_RESERVE", resourceType = "INBOUND", resourceId = "#request.businessId", description = "复用托盘主数据并预占库位", captureBefore = false)
public PalletAllocationDtos.BatchResult reserve(PalletAllocationDtos.ReserveRequest request) {
if (!Boolean.TRUE.equals(request.measurementCompleted())) {
throw new BizException(ResultCode.CONFLICT, "只能使用已完成测量的总体积拆分托盘并占用库位");
throw new BizException(ResultCode.CONFLICT, "只能为已确认的称重图片预占库位");
}
List<LocationDtos.ActiveResource> existing = active(request.tenantId(), request.sourceService(), request.businessId());
if (!existing.isEmpty()) {
locationResourceService.requireSameActiveReservations(request.tenantId(), request.warehouseId(),
request.sourceService(), request.businessId(), request.idempotencyKey(), request.pallets());
return fromActive(request.tenantId(), request.warehouseId(), existing, request.totalVolumeCbm());
}
@@ -69,12 +71,7 @@ public class PalletLocationAllocationService {
pallets.add(pallet);
}
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() + ":" + request.pallets().get(index).weightResultId()));
}
List<LocationDtos.ReservationTarget> targets = reservationTargets(request, pallets);
List<LocationDtos.ReservationResult> reservations = locationResourceService.reserveAvailable(
new LocationDtos.ReserveCommand(request.tenantId(), request.warehouseId(), request.sourceService(),
@@ -523,6 +520,17 @@ public class PalletLocationAllocationService {
return new ArrayList<>(locationResourceService.findActiveResources(tenantId, sourceService, businessId));
}
private static List<LocationDtos.ReservationTarget> reservationTargets(
PalletAllocationDtos.ReserveRequest request, List<PalletEntity> pallets) {
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() + ":" + request.pallets().get(index).weightResultId()));
}
return targets;
}
public List<LocationDtos.OccupiedResource> occupiedResources(long tenantId, String sourceService,
long businessId) {
return locationResourceService.findOccupiedResources(tenantId, sourceService, businessId);
@@ -2,11 +2,13 @@
SET NAMES utf8mb4;
USE baiyue_warehouse;
-- 编码由平台维护接口自动生成;两个国内作业仓各初始化一条可复用托盘主数据。
-- 两个国内作业仓各初始化木托盘、塑料托盘各一条,共四条可复用托盘主数据。
INSERT INTO wh_pallet(id,tenant_id,warehouse_id,pallet_code,pallet_type,capacity_cbm,weight_kg,color,remark,created_by,updated_by)
VALUES
(190000000000000101,0,190000000000000001,'TP-001','木托盘',1.200000,8.000,'黄色',NULL,0,0),
(190000000000000102,0,190000000000000002,'TP-002','胶托盘',2.400000,12.000,'黑色',NULL,0,0)
(190000000000000102,0,190000000000000002,'TP-002','塑料托盘',2.400000,12.000,'黑色',NULL,0,0),
(190000000000000103,0,190000000000000001,'YW-TP-002','塑料托盘',2.400000,12.000,'黑色',NULL,0,0),
(190000000000000104,0,190000000000000002,'DG-TP-002','木托盘',1.200000,8.000,'黄色',NULL,0,0)
ON DUPLICATE KEY UPDATE
tenant_id=0,
warehouse_id=VALUES(warehouse_id),
@@ -16,6 +18,31 @@ ON DUPLICATE KEY UPDATE
color=VALUES(color),
remark=VALUES(remark);
DROP PROCEDURE IF EXISTS assert_domestic_pallet_seed;
DELIMITER //
CREATE PROCEDURE assert_domestic_pallet_seed()
BEGIN
IF EXISTS (
SELECT expected.warehouse_id
FROM (
SELECT 190000000000000001 AS warehouse_id
UNION ALL SELECT 190000000000000002
) expected
LEFT JOIN wh_pallet pallet
ON pallet.tenant_id=0
AND pallet.warehouse_id=expected.warehouse_id
AND pallet.pallet_type IN ('木托盘','塑料托盘')
AND pallet.deleted=0
GROUP BY expected.warehouse_id
HAVING COUNT(DISTINCT pallet.pallet_type)<>2
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='国内作业仓木托盘、塑料托盘初始化不完整';
END IF;
END//
DELIMITER ;
CALL assert_domestic_pallet_seed();
DROP PROCEDURE IF EXISTS assert_domestic_pallet_seed;
-- 义乌仓初始化 A-F 六个库区,每区 A1-A15 共 90 个普通库位。
-- A0 仍是系统共享溢出位;东莞仓继续保留最小 A1 + A0 基线。
-- 这里只创建公共资源,不初始化预占、占用或业务单据。
@@ -28,8 +28,13 @@ class InitialWarehouseResourceSeedSqlContractTest {
private static void assertSeed(String sql, String yiwuId, String dongguanId, String idPrefix) {
assertThat(sql)
.contains("0," + yiwuId + ",'TP-001'")
.contains("0," + dongguanId + ",'TP-002'")
.contains("0," + yiwuId + ",'TP-001','木托盘'")
.contains("0," + dongguanId + ",'TP-002','塑料托盘'")
.contains("0," + yiwuId + ",'YW-TP-002','塑料托盘'")
.contains("0," + dongguanId + ",'DG-TP-002','木托盘'")
.contains("COUNT(DISTINCT pallet.pallet_type)")
.contains("国内作业仓木托盘、塑料托盘初始化不完整")
.doesNotContain("'胶托盘'")
.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")
@@ -72,6 +72,43 @@ class LocationResourceServiceTest {
});
}
@Test
void preservesZeroPackageOccupiedResourceForImageBasedAllocation() {
LocationOccupancyEntity active = occupancy(201L, 101L, 9L, "A1", 501L);
active.setAllocatedPackageCount(0);
when(repository.findOccupanciesByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(active));
assertThat(service.findOccupiedResources(10001L, "FIRSTLEG", 301L)).singleElement()
.extracting(item -> item.allocatedPackageCount()).isEqualTo(0);
}
@Test
void rejectsReplayWhenConfirmedImageReferencesChanged() {
LocationReservationEntity reservation = reservation("RESERVED");
reservation.setIdempotencyKey("measure-001:7001");
when(repository.findReservationsByBusiness(10001L, "FIRSTLEG", 301L))
.thenReturn(List.of(reservation));
assertThatThrownBy(() -> service.requireSameActiveReservations(10001L, 88L, "FIRSTLEG", 301L,
"measure-001", List.of(new com.baiyue.warehouse.dto.PalletAllocationDtos.PalletReference(7002L, 501L))))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class);
}
@Test
void allowsReplayWhenMultipleImagesReuseSamePalletMaster() {
LocationReservationEntity first = reservation("RESERVED");
first.setIdempotencyKey("measure-001:7001");
LocationReservationEntity second = reservation("RESERVED");
second.setId(102L);
second.setIdempotencyKey("measure-001:7002");
when(repository.findReservationsByBusiness(10001L, "FIRSTLEG", 301L))
.thenReturn(List.of(first, second));
service.requireSameActiveReservations(10001L, 88L, "FIRSTLEG", 301L, "measure-001", List.of(
new com.baiyue.warehouse.dto.PalletAllocationDtos.PalletReference(7001L, 501L),
new com.baiyue.warehouse.dto.PalletAllocationDtos.PalletReference(7002L, 501L)));
}
@Test
void shouldHandleCase01() {
when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse());
@@ -257,6 +294,22 @@ class LocationResourceServiceTest {
verify(repository, never()).releaseOccupancy(first, "PDA full pick");
}
@Test
void shouldReleaseZeroPackageLocationCreatedForConfirmedImage() {
LocationOccupancyEntity occupancy = occupancy(201L, 101L, 9L, "A1", 501L);
occupancy.setAllocatedPackageCount(0);
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(occupancy));
when(repository.findOwned(10001L, 88L, 9L)).thenReturn(occupiedLocation(9L, "A1"));
when(repository.releaseOccupancy(occupancy, "PDA full pick")).thenReturn(1);
when(repository.changeStatus(10001L, 88L, 9L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1);
var result = service.releaseSelectedExactly(new com.baiyue.warehouse.dto.LocationDtos.ExactSelectiveReleaseCommand(
10001L, "FIRSTLEG", 301L, 7L, "PDA full pick", List.of(9L)));
assertThat(result).singleElement()
.extracting(item -> item.allocatedPackageCount()).isEqualTo(0);
}
@Test
void shouldReleasePublicResourcesInAllocationOrderWhenLocationsAreNotSpecified() {
LocationOccupancyEntity allocationThree = occupancy(203L, 103L, 11L, "A3", 503L);
@@ -122,6 +122,7 @@ class PalletLocationAllocationServiceTest {
LocationDtos.ActiveResource resource = new LocationDtos.ActiveResource(
101, null, 20, 30, "TP-001", 40, "A-01", false, "RESERVED");
when(locations.findActiveResources(10001, "FIRSTLEG", 88)).thenReturn(List.of(resource));
when(pallets.findOwnedIncludingDeleted(10001, 20, 30)).thenReturn(pallet(30, "TP-001"));
var result = service.reserve(reserve(88));
@@ -130,6 +131,26 @@ class PalletLocationAllocationServiceTest {
verify(pallets, never()).findOwned(anyLong(), anyLong(), anyLong());
}
@Test
void validatesConfirmedImageReferencesBeforeReplayingAllocation() {
LocationDtos.ActiveResource resource = new LocationDtos.ActiveResource(
101L, null, 20L, 30L, "TP-001", 40L, "A-01", false, "RESERVED");
when(locations.findActiveResources(10001L, "FIRSTLEG", 88L)).thenReturn(List.of(resource));
when(pallets.findOwnedIncludingDeleted(10001L, 20L, 30L)).thenReturn(pallet(30L, "TP-001"));
service.reserve(reserve(88L));
org.mockito.ArgumentCaptor<List<PalletAllocationDtos.PalletReference>> targets =
org.mockito.ArgumentCaptor.forClass(List.class);
verify(locations).requireSameActiveReservations(eq(10001L), eq(20L), eq("FIRSTLEG"), eq(88L),
eq("measure-001"), targets.capture());
assertThat(targets.getValue()).singleElement().satisfies(item -> {
assertThat(item.weightResultId()).isEqualTo(7001L);
assertThat(item.palletId()).isEqualTo(30L);
});
verify(locations, never()).reserveAvailable(any());
}
@Test
void confirmPersistsPackageCountOnEveryOccupancyTarget() {
LocationDtos.ActiveResource resource = new LocationDtos.ActiveResource(