fix(warehouse-overseas): enforce pallet location invariant
This commit is contained in:
@@ -25,6 +25,8 @@
|
||||
- 平台和商户接口必须分别执行访问域与租户范围校验;客户端传入的 `tenantId` 不能作为唯一授权依据。
|
||||
- 平台维护的海外库位与实物托盘是公共资源,固定使用 `customer_id=0`;平台资源 API 不接收目标客户参数。资源锁记录仍使用真实业务客户 ID 隔离,但其托盘、库位查找和状态更新必须落到公共资源作用域。
|
||||
- 资源锁定以业务幂等键和 reservation 标识防重;释放/报废操作必须验证当前状态。
|
||||
- 可预留托盘必须满足 `IDLE`,且其绑定库位为 `VIRTUAL + ACTIVE` 或真实库位 `IDLE`;internal resolve 通过 `reservable`、`reservationBlockReason`、`locationType`、`locationStatus` 明示该事实,不得仅凭托盘状态宣称可预留。
|
||||
- PDA 托盘状态流转必须在同一事务内锁定托盘及源/目标库位:真实库位与托盘同步 `IDLE/OCCUPIED`,移库原子释放源库位并占用目标库位;虚拟库位始终保持 `ACTIVE`。释放后的可复用托盘保留已回到 `IDLE` 的真实库位关联。
|
||||
- 内部资源接口必须校验服务令牌;普通用户不得直接调用。
|
||||
- 本服务不得注册仓库主档创建、编辑、状态或商户仓库查询 Controller;所有资源写入先以相同 `warehouseId` 校验国内只读目录。
|
||||
|
||||
|
||||
+13
-2
@@ -8,11 +8,22 @@ public record WarehousePalletResolveResponse(
|
||||
String status,
|
||||
String fbaWarehouseCode,
|
||||
java.math.BigDecimal unitPrice,
|
||||
int version
|
||||
int version,
|
||||
String locationType,
|
||||
String locationStatus,
|
||||
boolean reservable,
|
||||
String reservationBlockReason
|
||||
) {
|
||||
public WarehousePalletResolveResponse(long palletId, String palletCode, long warehouseId, long locationId,
|
||||
String status, String fbaWarehouseCode, int version) {
|
||||
this(palletId, palletCode, warehouseId, locationId, status, fbaWarehouseCode,
|
||||
java.math.BigDecimal.ZERO, version);
|
||||
java.math.BigDecimal.ZERO, version, null, null, false, "LOCATION_STATE_UNKNOWN");
|
||||
}
|
||||
|
||||
public WarehousePalletResolveResponse(long palletId, String palletCode, long warehouseId, long locationId,
|
||||
String status, String fbaWarehouseCode,
|
||||
java.math.BigDecimal unitPrice, int version) {
|
||||
this(palletId, palletCode, warehouseId, locationId, status, fbaWarehouseCode,
|
||||
unitPrice, version, null, null, false, "LOCATION_STATE_UNKNOWN");
|
||||
}
|
||||
}
|
||||
|
||||
+12
-3
@@ -4,6 +4,7 @@ import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletOperationEntity;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
@@ -22,9 +23,17 @@ public interface WarehousePalletTransitionMapper extends BaseMapper<WarehousePal
|
||||
WarehousePalletEntity lockPublicPallet(@Param("warehouseId") long warehouseId,
|
||||
@Param("palletId") long palletId);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM wh_location WHERE id=#{locationId} AND customer_id=0 " +
|
||||
"AND warehouse_id=#{warehouseId} AND status<>'INACTIVE' AND deleted=0")
|
||||
long countActiveLocation(@Param("warehouseId") long warehouseId, @Param("locationId") long locationId);
|
||||
@Select("SELECT * FROM wh_location WHERE id=#{locationId} AND customer_id=0 " +
|
||||
"AND warehouse_id=#{warehouseId} AND deleted=0 LIMIT 1 FOR UPDATE")
|
||||
WarehouseLocationEntity lockPublicLocation(@Param("warehouseId") long warehouseId,
|
||||
@Param("locationId") long locationId);
|
||||
|
||||
@Update("UPDATE wh_location SET status=#{targetStatus},version=version+1,updated_at=CURRENT_TIMESTAMP(3) " +
|
||||
"WHERE id=#{locationId} AND customer_id=0 AND warehouse_id=#{warehouseId} " +
|
||||
"AND status=#{expectedStatus} AND deleted=0")
|
||||
int transitionLocation(@Param("warehouseId") long warehouseId, @Param("locationId") long locationId,
|
||||
@Param("expectedStatus") String expectedStatus,
|
||||
@Param("targetStatus") String targetStatus);
|
||||
|
||||
@Update("UPDATE wh_pallet SET status=#{targetStatus},location_id=#{targetLocationId},version=version+1," +
|
||||
"updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{palletId} AND customer_id=0 " +
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ public interface WarehouseResourceLockMapper extends BaseMapper<WarehouseResourc
|
||||
|
||||
@Update("""
|
||||
UPDATE wh_pallet
|
||||
SET status = 'IDLE', location_id = NULL, updated_at = CURRENT_TIMESTAMP(3), version = version + 1
|
||||
SET status = 'IDLE', updated_at = CURRENT_TIMESTAMP(3), version = version + 1
|
||||
WHERE id = #{palletId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId}
|
||||
AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0
|
||||
""")
|
||||
|
||||
+97
-8
@@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.warehouse.dto.PalletTransitionRequests;
|
||||
import com.baiyue.warehouse.dto.PalletTransitionResponse;
|
||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletEntity;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletOperationEntity;
|
||||
import com.baiyue.warehouse.mapper.WarehousePalletTransitionMapper;
|
||||
@@ -38,19 +39,16 @@ public class WarehousePalletTransitionService {
|
||||
|
||||
WarehousePalletEntity pallet = mapper.lockPublicPallet(request.warehouseId(), palletId);
|
||||
if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "当前冻结仓内托盘不存在");
|
||||
WarehousePalletOperationEntity concurrent = mapper.lockOperation(key);
|
||||
if (concurrent != null) return replay(concurrent, requestHash);
|
||||
int version = pallet.getVersion() == null ? 0 : pallet.getVersion();
|
||||
if (version != request.expectedVersion()) {
|
||||
throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请重新扫码");
|
||||
}
|
||||
if (mapper.countActiveLocation(request.warehouseId(), request.targetLocationId()) != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "目标库位无效或已停用");
|
||||
}
|
||||
if (pallet.getLocationId() == null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "托盘没有有效库位,不能执行库内作业");
|
||||
}
|
||||
if (mapper.transition(request.warehouseId(), palletId, status, request.targetLocationId(), version) != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请重新扫码");
|
||||
}
|
||||
transition(pallet, status, request.targetLocationId(), version);
|
||||
WarehousePalletOperationEntity operation = new WarehousePalletOperationEntity();
|
||||
operation.setId(IdWorker.getId()); operation.setOperationKey(key); operation.setRequestHash(requestHash);
|
||||
operation.setWarehouseId(request.warehouseId()); operation.setPalletId(palletId);
|
||||
@@ -75,15 +73,106 @@ public class WarehousePalletTransitionService {
|
||||
if (!"APPLIED".equals(operation.getOperationStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "托盘状态变更不可补偿");
|
||||
}
|
||||
if (mapper.transition(request.warehouseId(), palletId, operation.getBeforeStatus(),
|
||||
operation.getBeforeLocationId(), operation.getAfterVersion()) != 1) {
|
||||
WarehousePalletEntity pallet = mapper.lockPublicPallet(request.warehouseId(), palletId);
|
||||
if (pallet == null || !operation.getAfterStatus().equals(pallet.getStatus())
|
||||
|| !Long.valueOf(operation.getAfterLocationId()).equals(pallet.getLocationId())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "托盘已被后续作业修改,自动补偿失败");
|
||||
}
|
||||
transition(pallet, operation.getBeforeStatus(), operation.getBeforeLocationId(),
|
||||
operation.getAfterVersion());
|
||||
operation.setOperationStatus("COMPENSATED"); operation.setUpdatedAt(LocalDateTime.now());
|
||||
mapper.updateById(operation);
|
||||
return response(operation, false);
|
||||
}
|
||||
|
||||
private void transition(WarehousePalletEntity pallet, String targetStatus, long targetLocationId,
|
||||
int expectedVersion) {
|
||||
int currentVersion = pallet.getVersion() == null ? 0 : pallet.getVersion();
|
||||
if (currentVersion != expectedVersion || !MUTABLE_STATUSES.contains(pallet.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "托盘版本或状态已变化,请重新扫码");
|
||||
}
|
||||
long warehouseId = pallet.getWarehouseId();
|
||||
long sourceLocationId = pallet.getLocationId();
|
||||
WarehouseLocationEntity source;
|
||||
WarehouseLocationEntity target;
|
||||
if (sourceLocationId <= targetLocationId) {
|
||||
source = requireLocation(warehouseId, sourceLocationId);
|
||||
target = sourceLocationId == targetLocationId ? source : requireLocation(warehouseId, targetLocationId);
|
||||
} else {
|
||||
target = requireLocation(warehouseId, targetLocationId);
|
||||
source = requireLocation(warehouseId, sourceLocationId);
|
||||
}
|
||||
requireLocationMatchesPallet(source, pallet.getStatus());
|
||||
if (sourceLocationId != targetLocationId) requireAvailableTarget(target, targetStatus);
|
||||
|
||||
if (sourceLocationId == targetLocationId) {
|
||||
transitionSameLocation(warehouseId, source, pallet.getStatus(), targetStatus);
|
||||
} else {
|
||||
releaseSourceLocation(warehouseId, source, pallet.getStatus());
|
||||
occupyTargetLocation(warehouseId, target, targetStatus);
|
||||
}
|
||||
if (mapper.transition(warehouseId, pallet.getId(), targetStatus, targetLocationId, expectedVersion) != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请重新扫码");
|
||||
}
|
||||
}
|
||||
|
||||
private WarehouseLocationEntity requireLocation(long warehouseId, long locationId) {
|
||||
WarehouseLocationEntity location = mapper.lockPublicLocation(warehouseId, locationId);
|
||||
if (location == null || "INACTIVE".equals(location.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "目标库位无效或已停用");
|
||||
}
|
||||
return location;
|
||||
}
|
||||
|
||||
private void requireLocationMatchesPallet(WarehouseLocationEntity location, String palletStatus) {
|
||||
String expected = expectedLocationStatus(location, palletStatus);
|
||||
if (!expected.equals(location.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "托盘与库位状态不一致,请刷新资源状态");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireAvailableTarget(WarehouseLocationEntity location, String targetStatus) {
|
||||
String expected = isVirtual(location) ? "ACTIVE" : "IDLE";
|
||||
if (!expected.equals(location.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "目标库位不可用");
|
||||
}
|
||||
if (!MUTABLE_STATUSES.contains(targetStatus)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "托盘作业状态只允许 IDLE 或 OCCUPIED");
|
||||
}
|
||||
}
|
||||
|
||||
private void transitionSameLocation(long warehouseId, WarehouseLocationEntity location,
|
||||
String beforeStatus, String afterStatus) {
|
||||
if (beforeStatus.equals(afterStatus) || isVirtual(location)) return;
|
||||
changeLocationStatus(warehouseId, location.getId(), expectedLocationStatus(location, beforeStatus),
|
||||
expectedLocationStatus(location, afterStatus));
|
||||
}
|
||||
|
||||
private void releaseSourceLocation(long warehouseId, WarehouseLocationEntity source, String palletStatus) {
|
||||
if (isVirtual(source) || "IDLE".equals(palletStatus)) return;
|
||||
changeLocationStatus(warehouseId, source.getId(), "OCCUPIED", "IDLE");
|
||||
}
|
||||
|
||||
private void occupyTargetLocation(long warehouseId, WarehouseLocationEntity target, String palletStatus) {
|
||||
if (isVirtual(target) || "IDLE".equals(palletStatus)) return;
|
||||
changeLocationStatus(warehouseId, target.getId(), "IDLE", "OCCUPIED");
|
||||
}
|
||||
|
||||
private void changeLocationStatus(long warehouseId, long locationId, String before, String after) {
|
||||
if (before.equals(after)) return;
|
||||
if (mapper.transitionLocation(warehouseId, locationId, before, after) != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "库位状态已变化,请重新扫码");
|
||||
}
|
||||
}
|
||||
|
||||
private static String expectedLocationStatus(WarehouseLocationEntity location, String palletStatus) {
|
||||
return isVirtual(location) ? "ACTIVE" : palletStatus;
|
||||
}
|
||||
|
||||
private static boolean isVirtual(WarehouseLocationEntity location) {
|
||||
return "VIRTUAL".equals(location.getLocationType());
|
||||
}
|
||||
|
||||
private PalletTransitionResponse replay(WarehousePalletOperationEntity operation, String hash) {
|
||||
if (!hash.equals(operation.getRequestHash())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "操作幂等键已被不同托盘请求使用");
|
||||
|
||||
+22
-7
@@ -188,19 +188,14 @@ public class WarehouseResourceService {
|
||||
WarehousePalletEntity pallet = matches.get(0);
|
||||
if (pallet.getLocationId() == null) throw new BizException(ResultCode.CONFLICT, "托盘未绑定库位");
|
||||
if ("SCRAPPED".equals(pallet.getStatus())) throw new BizException(ResultCode.CONFLICT, "托盘已报废");
|
||||
return new WarehousePalletResolveResponse(pallet.getId(), pallet.getPalletCode(), pallet.getWarehouseId(),
|
||||
pallet.getLocationId(), pallet.getStatus(), pallet.getFbaWarehouseCode(),
|
||||
pallet.getUnitPrice(),
|
||||
pallet.getVersion() == null ? 0 : pallet.getVersion());
|
||||
return palletReference(pallet);
|
||||
}
|
||||
|
||||
public WarehousePalletResolveResponse resolvePublicPallet(long palletId) {
|
||||
WarehousePalletEntity pallet = repository.findPublicPallet(palletId);
|
||||
if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "托盘不存在");
|
||||
if (pallet.getLocationId() == null) throw new BizException(ResultCode.CONFLICT, "托盘未绑定库位");
|
||||
return new WarehousePalletResolveResponse(pallet.getId(), pallet.getPalletCode(), pallet.getWarehouseId(),
|
||||
pallet.getLocationId(), pallet.getStatus(), pallet.getFbaWarehouseCode(), pallet.getUnitPrice(),
|
||||
pallet.getVersion() == null ? 0 : pallet.getVersion());
|
||||
return palletReference(pallet);
|
||||
}
|
||||
|
||||
public WarehouseLocationResolveResponse resolvePublicLocation(long warehouseId, String locationCode) {
|
||||
@@ -222,6 +217,26 @@ public class WarehouseResourceService {
|
||||
location.getLocationType(), location.getMaxLoadKg(), location.getStatus());
|
||||
}
|
||||
|
||||
private WarehousePalletResolveResponse palletReference(WarehousePalletEntity pallet) {
|
||||
WarehouseLocationEntity location = repository.findLocation(PUBLIC_RESOURCE_SCOPE,
|
||||
pallet.getWarehouseId(), pallet.getLocationId());
|
||||
String blockReason = reservationBlockReason(pallet, location);
|
||||
return new WarehousePalletResolveResponse(pallet.getId(), pallet.getPalletCode(), pallet.getWarehouseId(),
|
||||
pallet.getLocationId(), pallet.getStatus(), pallet.getFbaWarehouseCode(), pallet.getUnitPrice(),
|
||||
pallet.getVersion() == null ? 0 : pallet.getVersion(),
|
||||
location == null ? null : location.getLocationType(),
|
||||
location == null ? null : location.getStatus(), blockReason == null, blockReason);
|
||||
}
|
||||
|
||||
private static String reservationBlockReason(WarehousePalletEntity pallet, WarehouseLocationEntity location) {
|
||||
if (!"IDLE".equals(pallet.getStatus())) return "PALLET_NOT_IDLE";
|
||||
if (location == null) return "LOCATION_NOT_FOUND";
|
||||
if ("VIRTUAL".equals(location.getLocationType())) {
|
||||
return "ACTIVE".equals(location.getStatus()) ? null : "VIRTUAL_LOCATION_NOT_ACTIVE";
|
||||
}
|
||||
return "IDLE".equals(location.getStatus()) ? null : "LOCATION_NOT_IDLE";
|
||||
}
|
||||
|
||||
public PageResult<WarehousePalletEntity> pallets(long customerId, long warehouseId,
|
||||
PalletRequests.QueryRequest query) {
|
||||
requireWarehouse(customerId, warehouseId);
|
||||
|
||||
+13
-2
@@ -15,7 +15,7 @@ class WarehousePalletTransitionMapperContractTest {
|
||||
@Test
|
||||
void annotationSqlUsesNativeComparisonOperatorsInsteadOfXmlEntities() throws Exception {
|
||||
Method locationQuery = WarehousePalletTransitionMapper.class.getMethod(
|
||||
"countActiveLocation", long.class, long.class);
|
||||
"lockPublicLocation", long.class, long.class);
|
||||
String selectSql = String.join(" ", locationQuery.getAnnotation(Select.class).value());
|
||||
|
||||
Method transition = Arrays.stream(WarehousePalletTransitionMapper.class.getMethods())
|
||||
@@ -24,9 +24,20 @@ class WarehousePalletTransitionMapperContractTest {
|
||||
.orElseThrow();
|
||||
String updateSql = String.join(" ", transition.getAnnotation(Update.class).value());
|
||||
|
||||
assertTrue(selectSql.contains("status<>'INACTIVE'"));
|
||||
assertTrue(selectSql.contains("FOR UPDATE"));
|
||||
assertTrue(updateSql.contains("status<>'SCRAPPED'"));
|
||||
assertFalse(selectSql.contains("<") || selectSql.contains(">"));
|
||||
assertFalse(updateSql.contains("<") || updateSql.contains(">"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalResourceReleaseKeepsPalletBoundToTheNowIdleLocation() throws Exception {
|
||||
Method release = WarehouseResourceLockMapper.class.getMethod("releasePallet",
|
||||
long.class, long.class, long.class, long.class);
|
||||
String sql = String.join(" ", release.getAnnotation(Update.class).value());
|
||||
|
||||
assertTrue(sql.contains("SET status = 'IDLE'"));
|
||||
assertFalse(sql.contains("location_id = NULL"));
|
||||
assertTrue(sql.contains("location_id = #{locationId}"));
|
||||
}
|
||||
}
|
||||
|
||||
+76
-2
@@ -2,6 +2,7 @@ package com.baiyue.warehouse.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.warehouse.dto.PalletTransitionRequests;
|
||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletEntity;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletOperationEntity;
|
||||
import com.baiyue.warehouse.mapper.WarehousePalletTransitionMapper;
|
||||
@@ -30,7 +31,9 @@ class WarehousePalletTransitionServiceTest {
|
||||
@Test void appliesVersionedPublicPalletTransitionAndPersistsCompensationReceipt() {
|
||||
WarehousePalletEntity pallet = pallet("P-1", "IDLE", 4);
|
||||
when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet);
|
||||
when(mapper.countActiveLocation(88L, 20L)).thenReturn(1L);
|
||||
when(mapper.lockPublicLocation(88L, 11L)).thenReturn(location(11L, "STORAGE", "IDLE"));
|
||||
when(mapper.lockPublicLocation(88L, 20L)).thenReturn(location(20L, "STORAGE", "IDLE"));
|
||||
when(mapper.transitionLocation(88L, 20L, "IDLE", "OCCUPIED")).thenReturn(1);
|
||||
when(mapper.transition(88L, 10L, "OCCUPIED", 20L, 4)).thenReturn(1);
|
||||
|
||||
var response = service.apply(10L, new PalletTransitionRequests.Apply(
|
||||
@@ -56,9 +59,26 @@ class WarehousePalletTransitionServiceTest {
|
||||
verify(mapper, never()).transition(anyLong(), anyLong(), anyString(), anyLong(), anyInt());
|
||||
}
|
||||
|
||||
@Test void concurrentExactReplayAfterPalletLockDoesNotMutatePhysicalStateTwice() {
|
||||
WarehousePalletOperationEntity existing = operation();
|
||||
when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-1", "OCCUPIED", 5, 20L));
|
||||
when(mapper.lockOperation("operation-1")).thenReturn(existing);
|
||||
|
||||
var response = service.apply(10L, new PalletTransitionRequests.Apply(
|
||||
88L, 4, "OCCUPIED", 20L, "operation-1", "assemble"));
|
||||
|
||||
assertTrue(response.replayed());
|
||||
verify(mapper, never()).transition(anyLong(), anyLong(), anyString(), anyLong(), anyInt());
|
||||
verify(mapper, never()).transitionLocation(anyLong(), anyLong(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test void compensationRequiresExactAppliedVersionAndRestoresBeforeState() {
|
||||
WarehousePalletOperationEntity existing = operation();
|
||||
when(mapper.lockOperation("operation-1")).thenReturn(existing);
|
||||
when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-1", "OCCUPIED", 5, 20L));
|
||||
when(mapper.lockPublicLocation(88L, 11L)).thenReturn(location(11L, "STORAGE", "IDLE"));
|
||||
when(mapper.lockPublicLocation(88L, 20L)).thenReturn(location(20L, "STORAGE", "OCCUPIED"));
|
||||
when(mapper.transitionLocation(88L, 20L, "OCCUPIED", "IDLE")).thenReturn(1);
|
||||
when(mapper.transition(88L, 10L, "IDLE", 11L, 5)).thenReturn(1);
|
||||
|
||||
var response = service.compensate(10L, "operation-1", new PalletTransitionRequests.Compensate(88L));
|
||||
@@ -67,6 +87,53 @@ class WarehousePalletTransitionServiceTest {
|
||||
verify(mapper).updateById(existing);
|
||||
}
|
||||
|
||||
@Test void unbindAtomicallyReturnsRealLocationToIdleWhileKeepingAssociation() {
|
||||
when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-1", "OCCUPIED", 4));
|
||||
when(mapper.lockPublicLocation(88L, 11L)).thenReturn(location(11L, "STORAGE", "OCCUPIED"));
|
||||
when(mapper.transitionLocation(88L, 11L, "OCCUPIED", "IDLE")).thenReturn(1);
|
||||
when(mapper.transition(88L, 10L, "IDLE", 11L, 4)).thenReturn(1);
|
||||
|
||||
service.apply(10L, new PalletTransitionRequests.Apply(
|
||||
88L, 4, "IDLE", 11L, "unbind-1", "unbind"));
|
||||
|
||||
verify(mapper).transitionLocation(88L, 11L, "OCCUPIED", "IDLE");
|
||||
verify(mapper).transition(88L, 10L, "IDLE", 11L, 4);
|
||||
}
|
||||
|
||||
@Test void virtualLocationRemainsActiveAcrossPalletStatusChanges() {
|
||||
when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-1", "OCCUPIED", 4));
|
||||
when(mapper.lockPublicLocation(88L, 11L)).thenReturn(location(11L, "VIRTUAL", "ACTIVE"));
|
||||
when(mapper.transition(88L, 10L, "IDLE", 11L, 4)).thenReturn(1);
|
||||
|
||||
service.apply(10L, new PalletTransitionRequests.Apply(
|
||||
88L, 4, "IDLE", 11L, "virtual-unbind", null));
|
||||
|
||||
verify(mapper, never()).transitionLocation(anyLong(), anyLong(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test void inconsistentIdlePalletAndOccupiedLocationIsRejectedBeforeMutation() {
|
||||
when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-1", "IDLE", 4));
|
||||
when(mapper.lockPublicLocation(88L, 11L)).thenReturn(location(11L, "STORAGE", "OCCUPIED"));
|
||||
|
||||
assertThrows(BizException.class, () -> service.apply(10L, new PalletTransitionRequests.Apply(
|
||||
88L, 4, "OCCUPIED", 11L, "broken-state", null)));
|
||||
|
||||
verify(mapper, never()).transition(anyLong(), anyLong(), anyString(), anyLong(), anyInt());
|
||||
verify(mapper, never()).transitionLocation(anyLong(), anyLong(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test void locationConflictStopsPalletMutationSoTransactionCanRollBackCleanly() {
|
||||
when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-1", "OCCUPIED", 4));
|
||||
when(mapper.lockPublicLocation(88L, 11L)).thenReturn(location(11L, "STORAGE", "OCCUPIED"));
|
||||
when(mapper.transitionLocation(88L, 11L, "OCCUPIED", "IDLE")).thenReturn(0);
|
||||
|
||||
assertThrows(BizException.class, () -> service.apply(10L, new PalletTransitionRequests.Apply(
|
||||
88L, 4, "IDLE", 11L, "location-race", null)));
|
||||
|
||||
verify(mapper, never()).transition(anyLong(), anyLong(), anyString(), anyLong(), anyInt());
|
||||
verify(mapper, never()).insert(any(WarehousePalletOperationEntity.class));
|
||||
}
|
||||
|
||||
@Test void staleVersionIsRejectedWithoutPhysicalMutation() {
|
||||
when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-1", "IDLE", 5));
|
||||
assertThrows(BizException.class, () -> service.apply(10L, new PalletTransitionRequests.Apply(
|
||||
@@ -75,10 +142,17 @@ class WarehousePalletTransitionServiceTest {
|
||||
}
|
||||
|
||||
private static WarehousePalletEntity pallet(String code, String status, int version) {
|
||||
return pallet(code, status, version, 11L);
|
||||
}
|
||||
private static WarehousePalletEntity pallet(String code, String status, int version, long locationId) {
|
||||
WarehousePalletEntity value = new WarehousePalletEntity(); value.setId(10L); value.setCustomerId(0L);
|
||||
value.setWarehouseId(88L); value.setLocationId(11L); value.setPalletCode(code);
|
||||
value.setWarehouseId(88L); value.setLocationId(locationId); value.setPalletCode(code);
|
||||
value.setStatus(status); value.setVersion(version); return value;
|
||||
}
|
||||
private static WarehouseLocationEntity location(long id, String type, String status) {
|
||||
WarehouseLocationEntity value = new WarehouseLocationEntity(); value.setId(id); value.setCustomerId(0L);
|
||||
value.setWarehouseId(88L); value.setLocationType(type); value.setStatus(status); return value;
|
||||
}
|
||||
private static WarehousePalletOperationEntity operation() {
|
||||
WarehousePalletOperationEntity value = new WarehousePalletOperationEntity(); value.setId(1L);
|
||||
value.setOperationKey("operation-1");
|
||||
|
||||
+40
@@ -18,6 +18,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -68,6 +69,34 @@ class WarehouseResourceServiceTest {
|
||||
assertThat(result.locationCode()).isEqualTo("A-01");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvePalletExplicitlyReportsReservableRealLocation() {
|
||||
WarehousePalletEntity pallet = pallet(40001L, "IDLE", 30001L);
|
||||
WarehouseLocationEntity location = location(30001L, "STORAGE", "IDLE");
|
||||
when(repository.findPublicPalletsByCode("PLT-01")).thenReturn(List.of(pallet));
|
||||
when(repository.findLocation(0L, WAREHOUSE, 30001L)).thenReturn(location);
|
||||
|
||||
var result = service.resolvePublicPallet(" PLT-01 ");
|
||||
|
||||
assertThat(result.reservable()).isTrue();
|
||||
assertThat(result.locationType()).isEqualTo("STORAGE");
|
||||
assertThat(result.locationStatus()).isEqualTo("IDLE");
|
||||
assertThat(result.reservationBlockReason()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvePalletDoesNotAdvertiseBrokenIdlePalletAsReservable() {
|
||||
WarehousePalletEntity pallet = pallet(40001L, "IDLE", 30001L);
|
||||
WarehouseLocationEntity location = location(30001L, "STORAGE", "OCCUPIED");
|
||||
when(repository.findPublicPalletsByCode("PLT-01")).thenReturn(List.of(pallet));
|
||||
when(repository.findLocation(0L, WAREHOUSE, 30001L)).thenReturn(location);
|
||||
|
||||
var result = service.resolvePublicPallet("PLT-01");
|
||||
|
||||
assertThat(result.reservable()).isFalse();
|
||||
assertThat(result.reservationBlockReason()).isEqualTo("LOCATION_NOT_IDLE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsLocationWithSnowflakeAssignedByMapper() {
|
||||
doAnswer(invocation -> {
|
||||
@@ -333,4 +362,15 @@ class WarehouseResourceServiceTest {
|
||||
private static SharedWarehouseReference warehouse() {
|
||||
return new SharedWarehouseReference(WAREHOUSE, "US-LAX-01", "Los Angeles", "ACTIVE");
|
||||
}
|
||||
|
||||
private static WarehousePalletEntity pallet(long id, String status, long locationId) {
|
||||
WarehousePalletEntity value = new WarehousePalletEntity(); value.setId(id); value.setCustomerId(0L);
|
||||
value.setWarehouseId(WAREHOUSE); value.setLocationId(locationId); value.setPalletCode("PLT-01");
|
||||
value.setStatus(status); value.setVersion(3); value.setUnitPrice(BigDecimal.ONE); return value;
|
||||
}
|
||||
|
||||
private static WarehouseLocationEntity location(long id, String type, String status) {
|
||||
WarehouseLocationEntity value = new WarehouseLocationEntity(); value.setId(id); value.setCustomerId(0L);
|
||||
value.setWarehouseId(WAREHOUSE); value.setLocationType(type); value.setStatus(status); return value;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user