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