fix: return A0404 for missing PDA pallets

This commit is contained in:
2026-09-04 14:07:18 +08:00
parent f6247ce232
commit 3399ae7488
13 changed files with 205 additions and 7 deletions
@@ -111,14 +111,26 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
.uri("/internal/v1/warehouse-pallets/resolve/{palletCode}", palletCode)
.header("X-Internal-Service-Token", internalToken)
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Warehouse rejected pallet code: HTTP " + result.getStatusCode());
throw palletLookupFailure(result.getStatusCode());
}).body(WarehousePalletResponse.class);
if (response == null || response.data() == null) throw new IllegalArgumentException("Warehouse returned an empty pallet response");
if (response != null && "A0404".equals(response.code())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse pallet was not found");
}
if (response == null || response.data() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Warehouse pallet service returned no usable response");
}
return new WarehousePalletReference(response.data().id(), response.data().palletCode(),
response.data().warehouseId(), response.data().locationId(), response.data().status(),
response.data().fbaWarehouseCode(), response.data().version());
}
private ResponseStatusException palletLookupFailure(HttpStatusCode status) {
return new ResponseStatusException(status.value() == 404 ? HttpStatus.NOT_FOUND
: (status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE),
status.value() == 404 ? "Warehouse pallet was not found" : "Warehouse pallet lookup failed");
}
@Override
public WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) {
WarehouseLocationResponse response = client.get()
@@ -173,7 +185,7 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
return response.data();
}
private record WarehousePalletResponse(WarehousePalletData data) {}
private record WarehousePalletResponse(String code, String message, WarehousePalletData data) {}
private record WarehousePalletData(@JsonAlias("palletId") long id, String palletCode, long warehouseId, long locationId,
String status, String fbaWarehouseCode, int version) {}
private record ReserveEnvelope(ReserveData data) {}
@@ -6,8 +6,10 @@ import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -31,12 +33,24 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway {
ResolveEnvelope response = client.get().uri("/internal/v1/warehouse-pallets/resolve/{code}", palletCode)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Warehouse rejected pallet scan: HTTP " + result.getStatusCode());
throw palletLookupFailure(result.getStatusCode());
}).body(ResolveEnvelope.class);
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no pallet");
if (response != null && "A0404".equals(response.code())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse pallet was not found");
}
if (response == null || response.data() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Warehouse pallet service returned no usable response");
}
return response.data();
}
private ResponseStatusException palletLookupFailure(HttpStatusCode status) {
return new ResponseStatusException(status.value() == 404 ? HttpStatus.NOT_FOUND
: (status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE),
status.value() == 404 ? "Warehouse pallet was not found" : "Warehouse pallet lookup failed");
}
@Override
public LocationReference resolveLocation(long warehouseId, long locationId) {
LocationEnvelope response = client.get().uri(uri -> uri
@@ -76,7 +90,7 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway {
}).toBodilessEntity();
}
private record ResolveEnvelope(PalletReference data) { }
private record ResolveEnvelope(String code, String message, PalletReference data) { }
private record LocationEnvelope(LocationReference data) { }
private record TransitionEnvelope(Transition data) { }
}
@@ -367,6 +367,7 @@ public class PdaPalletOperationService {
private PalletPhysicalGateway.PalletReference requirePallet(long warehouseId, String code) {
PalletPhysicalGateway.PalletReference pallet = physical.resolve(code(code));
if (pallet == null) throw notFound("未找到托盘");
if (pallet.warehouseId() != warehouseId) throw conflict("托盘不属于 Token 冻结的海外仓");
if ("SCRAPPED".equals(pallet.status())) throw conflict("已报废托盘不能执行作业");
return pallet;
@@ -517,6 +518,7 @@ public class PdaPalletOperationService {
private static long value(Long value) { return value == null ? 0L : value; }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
private record PlannedMove(long positionId, long packages, long expectedVersion,
long sourcePalletId, String sourcePalletCode,
@@ -51,6 +51,7 @@ public class PdaInventoryMoveService {
if (existing != null) return replay(existing, requestHash);
var pallet = warehouse.resolvePallet(required(request.sourcePalletCode(), "Source pallet code is required"));
if (pallet == null) throw notFound("Source pallet was not found");
if (pallet.warehouseId() != warehouseId) throw conflict("Source pallet belongs to another overseas warehouse");
var target = warehouse.resolveLocation(warehouseId,
required(request.targetLocationCode(), "Target location code is required"));
@@ -188,5 +189,6 @@ public class PdaInventoryMoveService {
private static long value(Long value) { return value == null ? 0 : value; }
private static String required(String value, String message) { if (value == null || value.isBlank()) throw badRequest(message); return value.trim(); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -42,6 +42,7 @@ public class PdaWarehouseService {
public PdaWarehouseResponses.Pallet pallet(long warehouseId, PdaWarehouseRequests.Scan request) {
WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(code(request.code()));
if (pallet == null) throw notFound("Warehouse pallet was not found");
if (pallet.warehouseId() != warehouseId) throw conflict("Pallet belongs to another overseas warehouse");
WarehouseResourceGateway.WarehouseLocationReference location = warehouse.resolveLocation(warehouseId,
pallet.locationId());
@@ -110,6 +110,7 @@ paths:
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryMove' } } } }
responses:
'200': { description: 移库结果, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryMoveResponse' } } } }
'404': { description: 来源托盘不存在(统一响应实际 HTTP 200,body.code=A0404), content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } } }
'409': { description: 跨仓、资源或库存版本冲突 }
/api/pda/v1/last-leg/inventory/resolve:
post:
@@ -130,6 +131,7 @@ paths:
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } }
responses:
'200': { description: 托盘及其库存内容, content: { application/json: { schema: { $ref: '#/components/schemas/PalletResponse' } } } }
'404': { description: 托盘不存在(统一响应实际 HTTP 200,body.code=A0404), content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } } }
/api/pda/v1/last-leg/operations:
get:
summary: 查询当前操作人的仓内操作历史
@@ -143,7 +145,7 @@ paths:
post:
summary: 扫码查询托盘及库存内容
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } }
responses: { '200': { description: Token绑定仓托盘内容 } }
responses: { '200': { description: Token绑定仓托盘内容 }, '404': { description: 托盘不存在(统一响应实际 HTTP 200,body.code=A0404), content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } } } }
/api/pda/v1/last-leg/pallet-operations/assemble:
post:
summary: 将多个来源托盘的指定库存行组到目标托盘
@@ -6,8 +6,10 @@ import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
@@ -57,4 +59,17 @@ class HttpWarehouseResourceGatewayTest {
server.verify();
}
@Test
void resolveMapsBusinessNotFoundEnvelopeToStableNotFound() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/MISSING"))
.andRespond(withSuccess("""
{"code":"A0404","message":"Not found","data":null}
""", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.resolvePallet("MISSING"))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("404");
server.verify();
}
}
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.palletops.gateway;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class HttpPalletPhysicalGatewayTest {
private HttpPalletPhysicalGateway gateway;
private MockRestServiceServer server;
@BeforeEach
void setUp() {
RestClient.Builder builder = RestClient.builder();
server = MockRestServiceServer.bindTo(builder).build();
gateway = new HttpPalletPhysicalGateway();
ReflectionTestUtils.setField(gateway, "client", builder.baseUrl("http://warehouse").build());
ReflectionTestUtils.setField(gateway, "internalToken", "test-token");
}
@Test
void resolveMapsBusinessNotFoundEnvelopeToStableNotFound() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/MISSING"))
.andRespond(withSuccess("""
{"code":"A0404","message":"Not found","data":null}
""", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.resolve("MISSING"))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("404");
server.verify();
}
}
@@ -63,6 +63,16 @@ class PdaPalletOperationServiceTest {
verifyNoInteractions(mapper);
}
@Test void resolveReportsMissingOnSitePalletAsNotFound() {
when(physical.resolve("MISSING")).thenReturn(null);
ResponseStatusException failure = assertThrows(ResponseStatusException.class, () -> service.resolve(identity(),
new PalletOperationRequests.Scan("MISSING")));
assertEquals(404, failure.getStatusCode().value());
verifyNoInteractions(mapper);
}
@Test void localConcurrencyFailureCompensatesEveryAppliedPhysicalTransitionInReverseSafeForm() {
var source = pallet(10L, "SOURCE", 88L, 1);
var target = pallet(20L, "TARGET", 88L, 2);
@@ -82,6 +82,7 @@ class PdaOperationsOpenApiContractTest {
assertTrue(yaml.contains("enum: [LOCATION, PALLET]"));
assertTrue(yaml.contains("enum: [APPROVE, REWORK]"));
assertTrue(yaml.contains("enum: [DAMAGED, MISSING_LABEL, LABEL_MISMATCH, UNREADABLE, OTHER]"));
assertEquals(3, occurrences(yaml, "body.code=A0404"));
assertFalse(yaml.contains("/api/pda/v1/last-leg/delivery"));
for (String response : List.of("StocktakeSummaryPageResponse", "StocktakeDetailResponse",
"StocktakeItemResponse", "InventoryMoveResponse", "InventoryScanResponse", "LocationResponse",
@@ -101,4 +102,8 @@ class PdaOperationsOpenApiContractTest {
return new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
}
private static int occurrences(String value, String token) {
return (value.length() - value.replace(token, "").length()) / token.length();
}
}
@@ -108,6 +108,16 @@ class PdaInventoryMoveServiceTest {
verify(physical, never()).apply(anyLong(), any(), anyString(), anyLong(), anyString(), any());
}
@Test
void missingSourcePalletIsReportedAsNotFound() {
when(warehouse.resolvePallet("MISSING")).thenReturn(null);
assertThatThrownBy(() -> service.move(900L, "operator-1",
new PdaInventoryMoveRequest("MISSING", "B-01", 2, "move-1", "库位优化")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("404");
verify(physical, never()).apply(anyLong(), any(), anyString(), anyLong(), anyString(), any());
}
@Test
void resumesWhenWarehouseCommittedButLastlegTransactionRolledBack() {
when(warehouse.resolvePallet("PAL-1")).thenReturn(pallet(900L, 21L, 3));
@@ -0,0 +1,77 @@
package com.baiyue.lastleg.pda.warehouse.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.common.web.GlobalExceptionHandler;
import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseRequests;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import org.springframework.web.server.ResponseStatusException;
import java.util.Set;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class PdaWarehouseControllerTest {
private PdaWarehouseService service;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
service = mock(PdaWarehouseService.class);
PdaWarehouseController controller = new PdaWarehouseController();
ReflectionTestUtils.setField(controller, "service", service);
mockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new GlobalExceptionHandler())
.setCustomArgumentResolvers(new AuthenticatedUserResolver())
.build();
}
@Test
void missingPalletReturnsHttp200WithStableA0404Body() throws Exception {
when(service.pallet(900L, new PdaWarehouseRequests.Scan("MISSING")))
.thenThrow(new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse pallet was not found"));
mockMvc.perform(post("/api/pda/v1/last-leg/pallets/resolve")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"code\":\"MISSING\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("A0404"))
.andExpect(jsonPath("$.message").value("Warehouse pallet was not found"));
}
private static AuthenticatedUser user() {
return new AuthenticatedUser(42L, 0L, 0L, 8L, "picker", "PLATFORM", "PDA",
Set.of("lastleg:pda:operate", "ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"),
false, null, null, false, 900L);
}
private static final class AuthenticatedUserResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthenticationPrincipal.class)
&& parameter.getParameterType() == AuthenticatedUser.class;
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer container,
NativeWebRequest request, WebDataBinderFactory binderFactory) {
return user();
}
}
}
@@ -48,6 +48,14 @@ class PdaWarehouseServiceTest {
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
}
@Test
void missingPalletIsReportedAsNotFound() {
when(warehouse.resolvePallet("MISSING")).thenReturn(null);
assertThatThrownBy(() -> service.pallet(900L, new PdaWarehouseRequests.Scan("MISSING")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("404");
}
@Test
void locationCombinesPhysicalCapacityAndInventoryOccupation() {
when(warehouse.resolveLocation(900L, "A-01")).thenReturn(