feat: complete overseas pda warehouse operations
This commit is contained in:
+39
@@ -0,0 +1,39 @@
|
||||
package com.baiyue.warehouse.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.warehouse.dto.PalletTransitionRequests;
|
||||
import com.baiyue.warehouse.dto.PalletTransitionResponse;
|
||||
import com.baiyue.warehouse.security.InternalServiceTokenGuard;
|
||||
import com.baiyue.warehouse.service.WarehousePalletTransitionService;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/internal/v1/pda-pallet-transitions")
|
||||
public class InternalPdaPalletTransitionController {
|
||||
@Resource private InternalServiceTokenGuard tokenGuard;
|
||||
@Resource private WarehousePalletTransitionService service;
|
||||
|
||||
@PostMapping("/{palletId}")
|
||||
public ApiResponse<PalletTransitionResponse> apply(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@PathVariable long palletId, @Valid @RequestBody PalletTransitionRequests.Apply request) {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(service.apply(palletId, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{palletId}/{operationKey}/compensate")
|
||||
public ApiResponse<PalletTransitionResponse> compensate(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@PathVariable long palletId, @PathVariable String operationKey,
|
||||
@Valid @RequestBody PalletTransitionRequests.Compensate request) {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(service.compensate(palletId, operationKey, request));
|
||||
}
|
||||
}
|
||||
+1
@@ -31,4 +31,5 @@ public class InternalWarehousePalletController {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(service.resolvePublicPallet(palletCode));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.warehouse.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public final class PalletTransitionRequests {
|
||||
private PalletTransitionRequests() { }
|
||||
|
||||
public record Apply(@Positive long warehouseId,
|
||||
@PositiveOrZero int expectedVersion,
|
||||
@NotBlank @Size(max = 20) String targetStatus,
|
||||
@NotNull @Positive Long targetLocationId,
|
||||
@NotBlank @Size(max = 128) String operationKey,
|
||||
@Size(max = 500) String reason) { }
|
||||
|
||||
public record Compensate(@Positive long warehouseId) { }
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.warehouse.dto;
|
||||
|
||||
public record PalletTransitionResponse(
|
||||
String operationKey,
|
||||
long palletId,
|
||||
String palletCode,
|
||||
long warehouseId,
|
||||
String beforeStatus,
|
||||
String afterStatus,
|
||||
long beforeLocationId,
|
||||
long afterLocationId,
|
||||
int beforeVersion,
|
||||
int afterVersion,
|
||||
String operationStatus,
|
||||
boolean replayed
|
||||
) { }
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.warehouse.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("wh_pallet_operation")
|
||||
public class WarehousePalletOperationEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private String operationKey;
|
||||
private String requestHash;
|
||||
private Long warehouseId;
|
||||
private Long palletId;
|
||||
private String palletCode;
|
||||
private String beforeStatus;
|
||||
private String afterStatus;
|
||||
private Long beforeLocationId;
|
||||
private Long afterLocationId;
|
||||
private Integer beforeVersion;
|
||||
private Integer afterVersion;
|
||||
private String operationStatus;
|
||||
private String reason;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+1
-2
@@ -3,5 +3,4 @@ package com.baiyue.warehouse.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletEntity;
|
||||
|
||||
public interface WarehousePalletMapper extends BaseMapper<WarehousePalletEntity> {
|
||||
}
|
||||
public interface WarehousePalletMapper extends BaseMapper<WarehousePalletEntity> { }
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.warehouse.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletEntity;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletOperationEntity;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
public interface WarehousePalletTransitionMapper extends BaseMapper<WarehousePalletOperationEntity> {
|
||||
@Select("SELECT * FROM wh_pallet_operation WHERE operation_key=#{operationKey} LIMIT 1")
|
||||
WarehousePalletOperationEntity findOperation(@Param("operationKey") String operationKey);
|
||||
|
||||
@Select("SELECT * FROM wh_pallet_operation WHERE operation_key=#{operationKey} LIMIT 1 FOR UPDATE")
|
||||
WarehousePalletOperationEntity lockOperation(@Param("operationKey") String operationKey);
|
||||
|
||||
@Select("SELECT * FROM wh_pallet WHERE id=#{palletId} AND customer_id=0 " +
|
||||
"AND warehouse_id=#{warehouseId} AND deleted=0 LIMIT 1 FOR UPDATE")
|
||||
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);
|
||||
|
||||
@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 " +
|
||||
"AND warehouse_id=#{warehouseId} AND version=#{expectedVersion} AND status<>'SCRAPPED' AND deleted=0")
|
||||
int transition(@Param("warehouseId") long warehouseId, @Param("palletId") long palletId,
|
||||
@Param("targetStatus") String targetStatus, @Param("targetLocationId") long targetLocationId,
|
||||
@Param("expectedVersion") int expectedVersion);
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package com.baiyue.warehouse.service;
|
||||
|
||||
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.PalletTransitionRequests;
|
||||
import com.baiyue.warehouse.dto.PalletTransitionResponse;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletEntity;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletOperationEntity;
|
||||
import com.baiyue.warehouse.mapper.WarehousePalletTransitionMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class WarehousePalletTransitionService {
|
||||
private static final Set<String> MUTABLE_STATUSES = Set.of("IDLE", "OCCUPIED");
|
||||
@Resource private WarehousePalletTransitionMapper mapper;
|
||||
|
||||
@Transactional
|
||||
public PalletTransitionResponse apply(long palletId, PalletTransitionRequests.Apply request) {
|
||||
String key = request.operationKey().trim();
|
||||
String status = request.targetStatus().trim().toUpperCase();
|
||||
if (!MUTABLE_STATUSES.contains(status)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "托盘作业状态只允许 IDLE 或 OCCUPIED");
|
||||
}
|
||||
String requestHash = hash(request.warehouseId() + "|" + palletId + "|" + request.expectedVersion()
|
||||
+ "|" + status + "|" + request.targetLocationId());
|
||||
WarehousePalletOperationEntity existing = mapper.findOperation(key);
|
||||
if (existing != null) return replay(existing, requestHash);
|
||||
|
||||
WarehousePalletEntity pallet = mapper.lockPublicPallet(request.warehouseId(), palletId);
|
||||
if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "当前冻结仓内托盘不存在");
|
||||
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, "托盘版本已变化,请重新扫码");
|
||||
}
|
||||
WarehousePalletOperationEntity operation = new WarehousePalletOperationEntity();
|
||||
operation.setId(IdWorker.getId()); operation.setOperationKey(key); operation.setRequestHash(requestHash);
|
||||
operation.setWarehouseId(request.warehouseId()); operation.setPalletId(palletId);
|
||||
operation.setPalletCode(pallet.getPalletCode()); operation.setBeforeStatus(pallet.getStatus());
|
||||
operation.setAfterStatus(status); operation.setBeforeLocationId(pallet.getLocationId());
|
||||
operation.setAfterLocationId(request.targetLocationId()); operation.setBeforeVersion(version);
|
||||
operation.setAfterVersion(version + 1); operation.setOperationStatus("APPLIED");
|
||||
operation.setReason(trim(request.reason())); operation.setCreatedAt(LocalDateTime.now());
|
||||
operation.setUpdatedAt(operation.getCreatedAt()); mapper.insert(operation);
|
||||
return response(operation, false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PalletTransitionResponse compensate(long palletId, String operationKey,
|
||||
PalletTransitionRequests.Compensate request) {
|
||||
WarehousePalletOperationEntity operation = mapper.lockOperation(operationKey);
|
||||
if (operation == null || !Long.valueOf(palletId).equals(operation.getPalletId())
|
||||
|| !Long.valueOf(request.warehouseId()).equals(operation.getWarehouseId())) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "托盘状态变更记录不存在");
|
||||
}
|
||||
if ("COMPENSATED".equals(operation.getOperationStatus())) return response(operation, true);
|
||||
if (!"APPLIED".equals(operation.getOperationStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "托盘状态变更不可补偿");
|
||||
}
|
||||
if (mapper.transition(request.warehouseId(), palletId, operation.getBeforeStatus(),
|
||||
operation.getBeforeLocationId(), operation.getAfterVersion()) != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "托盘已被后续作业修改,自动补偿失败");
|
||||
}
|
||||
operation.setOperationStatus("COMPENSATED"); operation.setUpdatedAt(LocalDateTime.now());
|
||||
mapper.updateById(operation);
|
||||
return response(operation, false);
|
||||
}
|
||||
|
||||
private PalletTransitionResponse replay(WarehousePalletOperationEntity operation, String hash) {
|
||||
if (!hash.equals(operation.getRequestHash())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "操作幂等键已被不同托盘请求使用");
|
||||
}
|
||||
return response(operation, true);
|
||||
}
|
||||
private static PalletTransitionResponse response(WarehousePalletOperationEntity value, boolean replayed) {
|
||||
return new PalletTransitionResponse(value.getOperationKey(), value.getPalletId(), value.getPalletCode(),
|
||||
value.getWarehouseId(), value.getBeforeStatus(), value.getAfterStatus(), value.getBeforeLocationId(),
|
||||
value.getAfterLocationId(), value.getBeforeVersion(), value.getAfterVersion(),
|
||||
value.getOperationStatus(), replayed);
|
||||
}
|
||||
private static String hash(String value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
|
||||
}
|
||||
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
}
|
||||
@@ -178,6 +178,20 @@ CREATE TABLE IF NOT EXISTS wh_pallet_business_binding (
|
||||
UNIQUE KEY uk_wh_pallet_binding_pallet (pallet_id)
|
||||
) ENGINE=InnoDB COMMENT='托盘业务绑定关系';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wh_pallet_operation (
|
||||
id BIGINT NOT NULL COMMENT '托盘状态操作ID', operation_key VARCHAR(128) NOT NULL COMMENT '跨服务幂等操作键',
|
||||
request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID',
|
||||
pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照',
|
||||
before_status VARCHAR(20) NOT NULL COMMENT '变更前托盘状态', after_status VARCHAR(20) NOT NULL COMMENT '变更后托盘状态',
|
||||
before_location_id BIGINT NOT NULL COMMENT '变更前库位ID', after_location_id BIGINT NOT NULL COMMENT '变更后库位ID',
|
||||
before_version INT NOT NULL COMMENT '变更前托盘版本', after_version INT NOT NULL COMMENT '变更后托盘版本',
|
||||
operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿',
|
||||
reason VARCHAR(500) NULL COMMENT '操作原因', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_operation_key (operation_key),
|
||||
KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='实物托盘状态幂等变更与补偿记录';
|
||||
|
||||
-- Every warehouse has one non-occupiable virtual location used for inventory history.
|
||||
INSERT INTO wh_location (id, customer_id, warehouse_id, zone_code, location_code, location_type,
|
||||
max_load_kg, status, remark)
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ class SchemaChineseCommentContractTest {
|
||||
Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8);
|
||||
|
||||
List<TableDefinition> tables = parseAndAssert(baseline, "仓储基线 SQL");
|
||||
assertEquals(7, tables.size(), "仓储表数量变化时必须同步表索引和备注迁移");
|
||||
assertEquals(8, tables.size(), "仓储表数量变化时必须同步表索引和备注迁移");
|
||||
parseAndAssert(incremental, "统一增量 SQL");
|
||||
|
||||
int marker = incremental.indexOf("2026-08-18 / 025 Chinese table and column comments");
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.baiyue.warehouse.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.warehouse.dto.PalletTransitionRequests;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletEntity;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletOperationEntity;
|
||||
import com.baiyue.warehouse.mapper.WarehousePalletTransitionMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WarehousePalletTransitionServiceTest {
|
||||
@Mock private WarehousePalletTransitionMapper mapper;
|
||||
private WarehousePalletTransitionService service;
|
||||
|
||||
@BeforeEach void setUp() {
|
||||
service = new WarehousePalletTransitionService();
|
||||
ReflectionTestUtils.setField(service, "mapper", mapper);
|
||||
}
|
||||
|
||||
@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.transition(88L, 10L, "OCCUPIED", 20L, 4)).thenReturn(1);
|
||||
|
||||
var response = service.apply(10L, new PalletTransitionRequests.Apply(
|
||||
88L, 4, "OCCUPIED", 20L, "operation-1", "assemble"));
|
||||
|
||||
assertEquals("APPLIED", response.operationStatus());
|
||||
assertEquals(5, response.afterVersion());
|
||||
ArgumentCaptor<WarehousePalletOperationEntity> captor = ArgumentCaptor.forClass(WarehousePalletOperationEntity.class);
|
||||
verify(mapper).insert(captor.capture());
|
||||
assertEquals("IDLE", captor.getValue().getBeforeStatus());
|
||||
assertEquals("OCCUPIED", captor.getValue().getAfterStatus());
|
||||
assertEquals(4, captor.getValue().getBeforeVersion());
|
||||
}
|
||||
|
||||
@Test void exactReplayDoesNotMutatePhysicalPalletAgain() {
|
||||
WarehousePalletOperationEntity existing = operation();
|
||||
when(mapper.findOperation("operation-1")).thenReturn(existing);
|
||||
|
||||
var response = service.apply(10L, new PalletTransitionRequests.Apply(
|
||||
88L, 4, "OCCUPIED", 20L, "operation-1", "any changed reason"));
|
||||
|
||||
assertTrue(response.replayed());
|
||||
verify(mapper, never()).transition(anyLong(), anyLong(), anyString(), anyLong(), anyInt());
|
||||
}
|
||||
|
||||
@Test void compensationRequiresExactAppliedVersionAndRestoresBeforeState() {
|
||||
WarehousePalletOperationEntity existing = operation();
|
||||
when(mapper.lockOperation("operation-1")).thenReturn(existing);
|
||||
when(mapper.transition(88L, 10L, "IDLE", 11L, 5)).thenReturn(1);
|
||||
|
||||
var response = service.compensate(10L, "operation-1", new PalletTransitionRequests.Compensate(88L));
|
||||
|
||||
assertEquals("COMPENSATED", response.operationStatus());
|
||||
verify(mapper).updateById(existing);
|
||||
}
|
||||
|
||||
@Test void staleVersionIsRejectedWithoutPhysicalMutation() {
|
||||
when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-1", "IDLE", 5));
|
||||
assertThrows(BizException.class, () -> service.apply(10L, new PalletTransitionRequests.Apply(
|
||||
88L, 4, "OCCUPIED", 20L, "operation-1", null)));
|
||||
verify(mapper, never()).transition(anyLong(), anyLong(), anyString(), anyLong(), anyInt());
|
||||
}
|
||||
|
||||
private static WarehousePalletEntity pallet(String code, String status, int version) {
|
||||
WarehousePalletEntity value = new WarehousePalletEntity(); value.setId(10L); value.setCustomerId(0L);
|
||||
value.setWarehouseId(88L); value.setLocationId(11L); value.setPalletCode(code);
|
||||
value.setStatus(status); value.setVersion(version); return value;
|
||||
}
|
||||
private static WarehousePalletOperationEntity operation() {
|
||||
WarehousePalletOperationEntity value = new WarehousePalletOperationEntity(); value.setId(1L);
|
||||
value.setOperationKey("operation-1");
|
||||
value.setRequestHash("f17b4386e6fdecbaab1d066ae4fefa47dba0687ff27361b8f92ef097d4e8072e");
|
||||
value.setWarehouseId(88L); value.setPalletId(10L); value.setPalletCode("P-1");
|
||||
value.setBeforeStatus("IDLE"); value.setAfterStatus("OCCUPIED"); value.setBeforeLocationId(11L);
|
||||
value.setAfterLocationId(20L); value.setBeforeVersion(4); value.setAfterVersion(5);
|
||||
value.setOperationStatus("APPLIED"); return value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user