diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md index 2ccd82a07..0662bfcfc 100644 --- a/warehouse-overseas-service/AGENTS.md +++ b/warehouse-overseas-service/AGENTS.md @@ -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` 校验国内只读目录。 diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java index 9cd58aea6..97cac56b1 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java @@ -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"); } } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java index 08a059dd7..3840ea4c2 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java @@ -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'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 " + diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java index 18718c9b5..491353fea 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java @@ -43,7 +43,7 @@ public interface WarehouseResourceLockMapper extends BaseMapper pallets(long customerId, long warehouseId, PalletRequests.QueryRequest query) { requireWarehouse(customerId, warehouseId); diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapperContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapperContractTest.java index 06f4c4d57..257490922 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapperContractTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapperContractTest.java @@ -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}")); + } } diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java index 5f998e23c..f0b5bb896 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java @@ -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"); diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java index c56bcbd9e..1d6615537 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java @@ -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; + } }