From 548a133de60a3997d71325e0a77c7426e1741512 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 06:34:23 +0800 Subject: [PATCH] fix(transfer): preserve inbound ownership and expose durable receipt recovery --- .../service/RelabelApplicationService.java | 2 +- .../dto/WarehouseTransferResponses.java | 6 ++ ...WarehouseTransferReceiptCommandMapper.java | 5 + .../WarehouseTransferReceiptCoordinator.java | 74 +++++++++++++- .../service/WarehouseTransferService.java | 14 ++- .../openapi/overseas-business-v1.yaml | 60 +++++++++++- .../RelabelApplicationServiceTest.java | 4 +- ...useTransferReceiptOpenApiContractTest.java | 14 +++ ...rehouseTransferReceiptCoordinatorTest.java | 45 ++++++++- .../service/WarehouseTransferServiceTest.java | 98 ++++++++++++++++++- 10 files changed, 306 insertions(+), 16 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 0051f4775..dec53919b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -968,7 +968,7 @@ public class RelabelApplicationService implements WalletResultHandler { LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId()); if (transferOrder != null && customerId.equals(transferOrder.getCustomerId())) { String transferOrderBefore = transferOrder.getCurrentStatus(); - transferOrder.setCurrentStatus(transferStatus); + if (!existingStockTransfer) transferOrder.setCurrentStatus(transferStatus); transferOrder.setFbaWarehouseCode(transfer.getFbaWarehouseCode()); transferOrder.setRelabelStatus("COMPLETED"); transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transferStatus + ";RELABEL:COMPLETED"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java index 3f2148aa8..205113cce 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java @@ -26,6 +26,11 @@ public final class WarehouseTransferResponses { public record Allocation(Long inventoryPositionId, Long inboundOrderId, String inboundCode, Long palletId, String palletNo, int packageCount, String status, Long destinationInventoryPositionId, String skuCode) {} + public record ReceiptTarget(String warehouseId, String warehouseCode, String warehouseName, + Long palletId, String palletNo, Long locationId, String locationCode, + String fbaId, String fbaCode, String fbaName) {} + public record ReceiptCommand(Long sourceInventoryPositionId, String status, String requestHash, + WarehouseTransferRequests.Receipt request, ReceiptTarget target) {} public record Summary(Long id, String transferNo, String inboundCode, Long lastlegOrderId, String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode, String destinationType, String destinationWarehouseCode, String destinationWarehouseName, @@ -51,6 +56,7 @@ public final class WarehouseTransferResponses { List sourceOrders, List items, List cartons, List pallets, List relabelBindings, List allocations, List measurementPhotos, + List receiptCommands, LocalDateTime customerConfirmedAt, LocalDateTime actualShipAt, LocalDateTime createdAt) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferReceiptCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferReceiptCommandMapper.java index f0f40d2dd..fd4053add 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferReceiptCommandMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferReceiptCommandMapper.java @@ -8,6 +8,11 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface WarehouseTransferReceiptCommandMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_receipt_command WHERE customer_id=#{customerId} " + + "AND transfer_order_id=#{transferId} ORDER BY id") + java.util.List listTransfer(@Param("customerId") String customerId, + @Param("transferId") Long transferId); + @Select("SELECT * FROM ll_warehouse_transfer_receipt_command WHERE customer_id=#{customerId} " + "AND transfer_order_id=#{transferId} AND source_inventory_position_id=#{sourceId} LIMIT 1 FOR UPDATE") WarehouseTransferReceiptCommand lockSource(@Param("customerId") String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinator.java index c31bda765..e952751bf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinator.java @@ -54,6 +54,72 @@ public class WarehouseTransferReceiptCoordinator { private record ResolvedTarget(Target target, String palletStatus) { } private record Admission(WarehouseTransferOrder transfer, boolean replay) { } + @org.springframework.transaction.annotation.Transactional(readOnly = true) + public java.util.List receiptCommands(String customerId, long transferId) { + var transfer = transfers.selectById(transferId); + if (transfer == null || !Objects.equals(customerId, transfer.getCustomerId())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse transfer order not found"); + } + var rows = commands.listTransfer(customerId, transferId); + require(rows != null, "持久实收命令读取失败,请联系管理员核查"); + java.util.Set sources = new java.util.HashSet<>(); + return rows.stream().map(row -> { + require(row != null && Objects.equals(customerId, row.getCustomerId()) && Objects.equals(transferId, row.getTransferOrderId()) + && row.getSourceInventoryPositionId() != null && row.getSourceInventoryPositionId() > 0 + && sources.add(row.getSourceInventoryPositionId()) + && row.getStatus() != null && Set.of("PREPARED", "RESERVED", "COMPLETED").contains(row.getStatus()), + "持久实收命令归属或状态无效,请联系管理员核查"); + var request = readSnapshot(row.getRequestJson(), WarehouseTransferRequests.Receipt.class); + var target = readSnapshot(row.getTargetJson(), Target.class); + require(Objects.equals(request.sourceInventoryPositionId(), row.getSourceInventoryPositionId()) + && text(request.idempotencyKey()) != null && text(request.idempotencyKey()).equals(row.getIdempotencyKey()) + && Objects.equals(hash(request), row.getRequestHash()), "持久实收原请求损坏,请联系管理员核查"); + long warehouseId = positive(target.warehouseId(), "持久实收目标损坏,请联系管理员核查"); + require(target.palletId() != null && target.palletId() > 0 && target.locationId() != null && target.locationId() > 0 + && text(target.warehouseCode()) != null && text(target.warehouseName()) != null + && text(target.palletNo()) != null && text(target.locationCode()) != null && text(target.fbaCode()) != null + && text(target.fbaName()) != null && positive(target.fbaId(), "持久实收目标损坏,请联系管理员核查") > 0 + && Objects.equals(row.getTargetWarehouseId(), warehouseId) + && Objects.equals(row.getTargetPalletId(), target.palletId()) + && Objects.equals(row.getTargetLocationId(), target.locationId()) + && Objects.equals(row.getTargetHash(), hash(new Binding(target.warehouseId(), target.palletId(), target.locationId(), target.fbaCode()))) + && positive(request.destinationOverseasWarehouseId(), "持久实收原请求损坏,请联系管理员核查") == warehouseId + && Objects.equals(request.destinationPalletId(), target.palletId()) + && Objects.equals(text(request.destinationPalletNo()), target.palletNo()) + && Objects.equals(text(request.destinationWarehouseCode()), target.fbaCode()) + && (text(request.destinationWarehouseId()) == null || positive(request.destinationWarehouseId(), + "持久实收原请求损坏,请联系管理员核查") == positive(target.fbaId(), "持久实收目标损坏,请联系管理员核查")) + && (request.warehouseLocationId() == null || Objects.equals(request.warehouseLocationId(), target.locationId())) + && ("PREPARED".equals(row.getStatus()) || row.getResourceLockId() != null && row.getResourceLockId() > 0), + "持久实收目标损坏,请联系管理员核查"); + if ("COMPLETED".equals(row.getStatus())) { + var receipt = receipts.findSource(customerId, transferId, row.getSourceInventoryPositionId()); + require(receipt != null && Objects.equals(receipt.getCustomerId(), customerId) + && Objects.equals(receipt.getTransferOrderId(), transferId) + && Objects.equals(receipt.getRequestHash(), row.getRequestHash()) + && Objects.equals(receipt.getIdempotencyKey(), row.getIdempotencyKey()), + "持久实收完成凭据缺失,请联系管理员核查"); + } + return new WarehouseTransferResponses.ReceiptCommand(row.getSourceInventoryPositionId(), row.getStatus(), + row.getRequestHash(), request, new WarehouseTransferResponses.ReceiptTarget(target.warehouseId(), + target.warehouseCode(), target.warehouseName(), target.palletId(), target.palletNo(), + target.locationId(), target.locationCode(), target.fbaId(), target.fbaCode(), target.fbaName())); + }).toList(); + } + + private T readSnapshot(String payload, Class type) { + try { + com.fasterxml.jackson.databind.JsonNode tree = objectMapper.reader().with(com.fasterxml.jackson.core.JsonParser.Feature.STRICT_DUPLICATE_DETECTION) + .with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(payload); + require(tree != null && tree.isObject(), "持久实收快照损坏,请联系管理员核查"); + var fields = java.util.Arrays.stream(type.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName) + .collect(java.util.stream.Collectors.toSet()); + tree.fieldNames().forEachRemaining(field -> require(fields.contains(field), "持久实收快照含未知字段,请联系管理员核查")); + return objectMapper.treeToValue(tree, type); + } catch (ResponseStatusException failure) { throw failure; } + catch (Exception failure) { throw new ResponseStatusException(HttpStatus.CONFLICT, "持久实收快照损坏,请联系管理员核查"); } + } + public WarehouseTransferResponses.Detail receive(String customerId, String transferNo, String operatorId, WarehouseTransferRequests.Receipt request, Supplier replay, Function stockWork) { @@ -99,9 +165,11 @@ public class WarehouseTransferReceiptCoordinator { if (current.replay()) return replay.get(); require("RESERVED".equals(owned.getStatus()) && Objects.equals(owned.getResourceLockId(), lockId), "Receipt has no active reserved resource"); - WarehouseTransferResponses.Detail result = stockWork.apply(new Context(target, lockId, owned.getId())); - owned.setStatus("COMPLETED"); owned.setUpdatedAt(LocalDateTime.now()); commands.updateById(owned); - return result; + stockWork.apply(new Context(target, lockId, owned.getId())); + owned.setStatus("COMPLETED"); owned.setUpdatedAt(LocalDateTime.now()); + require(commands.updateById(owned) == 1, "Receipt completion changed concurrently"); + // Read inside the same transaction after completion, so the response cannot leak the earlier RESERVED view. + return replay.get(); }); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index fef0d4215..dc4ec7b42 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -601,6 +601,7 @@ public class WarehouseTransferService { transfer.getRelabelContactPhone(), transfer.getRelabelDeliveryMethod(), transfer.getReviewDecision(), transfer.getReviewRemark(), transfer.getReviewedBy(), transfer.getReviewedAt(), sources, items, cartons, pallets, bindings, allocations, photos, + receiptCoordinator.receiptCommands(customerId, transfer.getId()), transfer.getCustomerConfirmedAt(), transfer.getOutboundAt(), transfer.getCreatedAt()); } @@ -1129,8 +1130,7 @@ public class WarehouseTransferService { transfer.setAllocationIdempotencyKey(key); transfer.setAllocationRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer); LastlegOrder order = requireTailOrder(customerId, transfer); - String before = order.getCurrentStatus(); order.setCurrentStatus( - Boolean.TRUE.equals(transfer.getNeedRelabel()) ? INBOUND_COMPLETED : "WAIT_OUTBOUND"); + String before = order.getCurrentStatus(); order.setCurrentStatus(INBOUND_COMPLETED); if (Boolean.TRUE.equals(transfer.getNeedRelabel())) { order.setRelabelOrderNo(transfer.getRelabelNo()); order.setRelabelStatus("PENDING_REVIEW"); } @@ -1203,7 +1203,7 @@ public class WarehouseTransferService { transfer.setOutboundRequestHash(requestHash); transfer.setOutboundAt(now); transfer.setUpdatedAt(now); transfers.updateById(transfer); LastlegOrder order = requireTailOrder(customerId, transfer); - String before = order.getCurrentStatus(); order.setCurrentStatus("IN_TRANSIT"); + String before = order.getCurrentStatus(); order.setSubOrderStatus("WAREHOUSE_TRANSFER:OUTBOUNDED"); order.setUpdatedAt(now); orders.updateById(order); insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, @@ -1254,12 +1254,16 @@ public class WarehouseTransferService { if (sourceBalance == null || !customerId.equals(sourceBalance.getCustomerId())) { throw conflict("Source inventory balance is missing for warehouse transfer receipt"); } + if (sourceBalance.getInboundOrderId() == null || sourceBalance.getInboundOrderId() <= 0 + || !StringUtils.hasText(sourceBalance.getInboundOrderNo()) || !StringUtils.hasText(sourceBalance.getInboundCode())) { + throw conflict("Source inventory inbound identity is missing for warehouse transfer receipt"); + } WarehouseTransferReceiptCoordinator.Target target = context.target(); InventoryCommandResponse result = inventory.stockIn(customerId, operatorId, new InventoryCommandRequests.StockIn(customerId, sourceBalance.getCustomerName(), target.warehouseId(), target.warehouseCode(), target.warehouseName(), target.fbaId(), target.fbaCode(), target.fbaName(), - transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(), transferNo, + sourceBalance.getInboundOrderId(), sourceBalance.getInboundOrderNo(), sourceBalance.getInboundCode(), Boolean.TRUE.equals(transfer.getNeedRelabel()) ? allocation.getSkuCode() : sourceBalance.getSkuCode(), sourceBalance.getProductNameZh(), sourceBalance.getProductNameEn(), target.palletId(), target.palletNo(), @@ -1281,7 +1285,7 @@ public class WarehouseTransferService { || "RECEIVED".equals(value.getStatus())); transfer.setStatus(complete ? RECEIVED : PARTIALLY_RECEIVED); transfer.setUpdatedAt(now); transfers.updateById(transfer); LastlegOrder order = requireTailOrder(customerId, transfer); - String before = order.getCurrentStatus(); order.setCurrentStatus(complete ? "COMPLETED" : "IN_TRANSIT"); + String before = order.getCurrentStatus(); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + (complete ? RECEIVED : PARTIALLY_RECEIVED)); order.setUpdatedAt(now); orders.updateById(order); insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 62b0bcf3f..1f6ca180d 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1106,7 +1106,7 @@ paths: idempotencyKey: { type: string, maxLength: 255 } remark: { type: string, nullable: true, maxLength: 1000 } responses: - '200': { description: 原转运详情,部分来源PARTIALLY_RECEIVED、全部来源RECEIVED;原键原载荷返回原收货结果 } + '200': { description: 原转运详情,部分来源PARTIALLY_RECEIVED、全部来源RECEIVED;receiptCommands包含真正COMPLETED的本次命令,原键原载荷返回持久结果, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferDetailApiResponse' } } } } '409': { description: 原请求冲突、来源状态或目标资源归属不匹配;不得换键绕过 } '503': { description: 资源服务不可用,保留原收货命令待核对恢复 } /api/platform/v1/last-leg/warehouse-transfers/{transferNo}/outbound: @@ -1193,7 +1193,7 @@ paths: description: 权限 merchant:overseas-warehouse:transfer:list;不泄露其他客户订单。 security: [{ bearerAuth: [] }] parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: 当前客户仓储转运单详情 }, '403': { description: 缺少仓储转运查看权限 }, '404': { description: 当前客户下转运单不存在 } } + responses: { '200': { description: 当前客户仓储转运单详情,receiptCommands仅本客户本票;未知状态或损坏快照拒绝返回,不当作新请求, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferDetailApiResponse' } } } }, '403': { description: 缺少仓储转运查看权限 }, '404': { description: 当前客户下转运单不存在 }, '409': { description: 持久实收命令状态、原请求或目标损坏,须核查原命令 } } /api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/confirm-measurement: post: summary: 客户确认仓储转运实测数据 @@ -1844,6 +1844,62 @@ paths: responses: { '200': { description: 退货箱唛打印数据 }, '404': { description: 当前仓没有该退货箱 } } components: schemas: + WarehouseTransferDetailApiResponse: + type: object + properties: + data: { $ref: '#/components/schemas/WarehouseTransferDetail' } + WarehouseTransferDetail: + type: object + description: 保留原转运详情字段;新增持久实收命令投影供平台与当前客户恢复。成功实收返回本地事务完成后的命令状态,不暴露内部资源锁或reservationKey。 + required: [receiptCommands] + properties: + receiptCommands: + type: array + items: { $ref: '#/components/schemas/WarehouseTransferReceiptCommand' } + WarehouseTransferReceiptCommand: + type: object + additionalProperties: false + required: [sourceInventoryPositionId, status, requestHash, request, target] + properties: + sourceInventoryPositionId: { type: string, description: 原来源库存位置标识,仅用于原命令恢复 } + status: { type: string, enum: [PREPARED, RESERVED, COMPLETED] } + requestHash: { type: string, pattern: '^[a-f0-9]{64}$' } + request: { $ref: '#/components/schemas/WarehouseTransferReceiptRequest' } + target: { $ref: '#/components/schemas/WarehouseTransferReceiptTarget' } + WarehouseTransferReceiptTarget: + type: object + additionalProperties: false + required: [warehouseId, warehouseCode, warehouseName, palletId, palletNo, locationId, locationCode, fbaId, fbaCode, fbaName] + properties: + warehouseId: { type: string } + warehouseCode: { type: string } + warehouseName: { type: string } + palletId: { type: string } + palletNo: { type: string } + locationId: { type: string } + locationCode: { type: string } + fbaId: { type: string } + fbaCode: { type: string } + fbaName: { type: string } + WarehouseTransferReceiptRequest: + type: object + additionalProperties: false + required: [sourceInventoryPositionId, destinationOverseasWarehouseId, destinationWarehouseCode, destinationPalletId, destinationPalletNo, idempotencyKey] + properties: + sourceInventoryPositionId: { type: integer, format: int64 } + destinationOverseasWarehouseId: { type: string, maxLength: 128 } + destinationOverseasWarehouseCode: { type: string, nullable: true, maxLength: 128 } + destinationOverseasWarehouseName: { type: string, nullable: true, maxLength: 255 } + destinationWarehouseId: { type: string, nullable: true, maxLength: 128 } + destinationWarehouseCode: { type: string, maxLength: 128 } + destinationWarehouseName: { type: string, nullable: true, maxLength: 255 } + destinationPalletId: { type: integer, format: int64 } + destinationPalletNo: { type: string, maxLength: 128 } + batchNo: { type: string, nullable: true, maxLength: 128 } + warehouseLocationId: { type: integer, format: int64, nullable: true } + warehouseLocationCode: { type: string, nullable: true, maxLength: 128 } + idempotencyKey: { type: string, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 1000 } ExceptionOrderDetailResponse: type: object properties: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index a86a0e1ff..5cf993e4a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -714,14 +714,14 @@ class RelabelApplicationServiceTest { var transfer=new WarehouseTransferOrder();transfer.setId(77L);transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1");transfer.setStatus("WAIT_RELABEL");transfer.setLastlegOrderId(88L); transfer.setAllocationIdempotencyKey("ORIGINAL-SOURCE");transfer.setFbaWarehouseCode("ONT8"); - var transferOrder=order(88,"10001","LMO-TRANSFER-1","WAIT_RELABEL"); + var transferOrder=order(88,"10001","LMO-TRANSFER-1","INBOUND_COMPLETED"); when(applications.lockNo("10001","FBR-1")).thenReturn(application);stubDetail(application); when(warehouseTransfers.lockByRelabelNo("10001","FBR-1")).thenReturn(transfer); when(orders.selectById(88L)).thenReturn(transferOrder); var command=new RelabelRequests.CustomerConfirm("CONFIRM-ORIGINAL","accepted"); service.confirmCompletion("10001","FBR-1","customer-1",command); assertEquals("ALLOCATED",transfer.getStatus());assertEquals("WAIT_TRANSFER_OUTBOUND",application.getFulfillmentStatus()); - assertEquals("ALLOCATED",transferOrder.getCurrentStatus()); + assertEquals("INBOUND_COMPLETED",transferOrder.getCurrentStatus()); service.confirmCompletion("10001","FBR-1","customer-1",command); verify(transferRelabelCompletion).complete(eq(transfer),eq(application),any(),eq("customer-1"),any()); verify(outboundOrders,never()).insert(any(OutboundOrder.class)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferReceiptOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferReceiptOpenApiContractTest.java index a37b6e306..8c6b9a3b4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferReceiptOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferReceiptOpenApiContractTest.java @@ -11,6 +11,20 @@ import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; class WarehouseTransferReceiptOpenApiContractTest { + @Test + @SuppressWarnings("unchecked") + void durableProjectionDocumentsExactPublicRecordsWithoutInternalLocks() { + Map document=new Yaml().load(getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml")); + var schemas=(Map)((Map)document.get("components")).get("schemas"); + for(var entry:Map.of("WarehouseTransferReceiptCommand",com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.ReceiptCommand.class, + "WarehouseTransferReceiptTarget",com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.ReceiptTarget.class, + "WarehouseTransferReceiptRequest",WarehouseTransferRequests.Receipt.class).entrySet()) { + var properties=(Map)((Map)schemas.get(entry.getKey())).get("properties"); + assertThat(properties.keySet()).isEqualTo(Arrays.stream(entry.getValue().getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName).collect(Collectors.toSet())); + assertThat(properties).doesNotContainKeys("resourceLockId","reservationKey"); + } + } @Test @SuppressWarnings("unchecked") void actualReceiptEndpointDocumentsExactDtoAndDurableResourceBoundary() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinatorTest.java index 8030daa98..8a959bfab 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinatorTest.java @@ -115,8 +115,13 @@ class WarehouseTransferReceiptCoordinatorTest { return new WarehouseTransferRequests.Receipt(source, "9", "NY", "NY warehouse", "30", "ABE4", "ABE4 warehouse", pallet, "PAL-" + pallet, "BATCH", pallet + 20, "LOC-" + (pallet + 20), key, "TEST"); } - private void receive(String no, WarehouseTransferRequests.Receipt request) { - coordinator.receive("7", no, "operator", request, () -> null, context -> { + private com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.Detail receive(String no, WarehouseTransferRequests.Receipt request) { + return coordinator.receive("7", no, "operator", request, () -> { + var view = mock(com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.Detail.class); + var head = session.getMapper(WarehouseTransferOrderMapper.class).findNo("7", no); + when(view.receiptCommands()).thenReturn(coordinator.receiptCommands("7", head.getId())); + return view; + }, context -> { assertTrue(TransactionSynchronizationManager.isActualTransactionActive()); stockCalls.incrementAndGet(); assertEquals(30L, Long.parseLong(context.target().fbaId())); assertTrue(context.resourceLockId() > 0); jdbc.update("INSERT INTO local_effect(type,source_id) VALUES('STOCK_IN',?)", request.sourceInventoryPositionId()); @@ -160,6 +165,42 @@ class WarehouseTransferReceiptCoordinatorTest { assertEquals(1L, jdbc.queryForObject("SELECT COUNT(*) FROM local_effect WHERE type='STOCK_IN'", Long.class)); verify(warehouse, never()).release(anyString(), anyLong()); } + + @Test void durableProjectionRecoversReservedOriginalPayloadAndSuccessfulResponseIsCompleted() throws Exception { + var original = request(101,20,"KEY-1"); failLocal.set(true); + assertThrows(IllegalStateException.class, () -> receive("WT-1",original)); + var reserved = coordinator.receiptCommands("7",1L); + assertEquals(1,reserved.size()); assertEquals("RESERVED",reserved.get(0).status()); + assertEquals(original,reserved.get(0).request()); assertEquals(hash(original),reserved.get(0).requestHash()); + assertEquals(20L,reserved.get(0).target().palletId()); + assertEquals(reserved,coordinator.receiptCommands("7",1L)); + int before=reserveCalls.get();var success=receive("WT-1",original); + assertEquals("COMPLETED",success.receiptCommands().get(0).status()); + assertEquals(1,occupancies.get());assertEquals(before+1,reserveCalls.get()); + var again=receive("WT-1",original);assertEquals(success.receiptCommands(),again.receiptCommands()); + assertEquals(before+1,reserveCalls.get());assertEquals(1,count("ll_warehouse_transfer_receipt")); + var serialized=json.writeValueAsString(success.receiptCommands()); + assertFalse(serialized.contains("resourceLockId"));assertFalse(serialized.contains("reservationKey")); + } + + @Test void durableProjectionIsCustomerAndTransferScopedAndRejectsCorruptionWithoutRemoteCalls() { + loseReserveResponse.set(true);assertThrows(ResponseStatusException.class,()->receive("WT-1",request(101,20,"KEY-1"))); + assertEquals("PREPARED",coordinator.receiptCommands("7",1L).get(0).status()); + addTransfer(2,"WT-2",102); + assertEquals(List.of(),coordinator.receiptCommands("7",2L)); + assertEquals(404,assertThrows(ResponseStatusException.class,()->coordinator.receiptCommands("8",1L)).getStatusCode().value()); + int calls=reserveCalls.get(); + String saved=jdbc.queryForObject("SELECT request_json FROM ll_warehouse_transfer_receipt_command WHERE transfer_order_id=1",String.class); + for(String payload:List.of("not-json","{}","{\"sourceInventoryPositionId\":101,\"unknown\":true}")) { + jdbc.update("UPDATE ll_warehouse_transfer_receipt_command SET request_json=? WHERE transfer_order_id=1",payload); + assertEquals(409,assertThrows(ResponseStatusException.class,()->coordinator.receiptCommands("7",1L)).getStatusCode().value()); + } + jdbc.update("UPDATE ll_warehouse_transfer_receipt_command SET request_json=?,status='UNKNOWN' WHERE transfer_order_id=1",saved); + assertEquals(409,assertThrows(ResponseStatusException.class,()->coordinator.receiptCommands("7",1L)).getStatusCode().value()); + jdbc.update("UPDATE ll_warehouse_transfer_receipt_command SET status='PREPARED',target_json='{}' WHERE transfer_order_id=1"); + assertEquals(409,assertThrows(ResponseStatusException.class,()->coordinator.receiptCommands("7",1L)).getStatusCode().value()); + assertEquals(calls,reserveCalls.get());assertEquals(0,count("ll_warehouse_transfer_receipt")); + } @Test void responseLostAfterRemoteCommitLeavesPreparedIntentRecoverableWithoutNewOccupancy() { loseReserveResponse.set(true); var request = request(101, 20, "KEY-1"); assertThrows(ResponseStatusException.class, () -> receive("WT-1", request)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index 7db6c3cf5..2bb203921 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -81,6 +81,18 @@ import static org.mockito.Mockito.times; @ExtendWith(MockitoExtension.class) class WarehouseTransferServiceTest { + @Test + void detailIncludesOnlyTheCoordinatorScopedDurableReceiptProjection() { + var transfer=transfer(WarehouseTransferService.PARTIALLY_RECEIVED); + when(transfers.findNo("TENANT-A","WTR-1")).thenReturn(transfer); + var command=new com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.ReceiptCommand(501L, + "RESERVED","a".repeat(64),receiptRequest(),new com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.ReceiptTarget( + "900","NY","NY warehouse",801L,"PALLET-DEST",901L,"LOC-DEST","30","ONT8","Amazon ONT8")); + when(receiptCoordinator.receiptCommands("TENANT-A",21L)).thenReturn(List.of(command)); + var detail=service.detail("TENANT-A","WTR-1"); + assertEquals(List.of(command),detail.receiptCommands()); + verify(receiptCoordinator).receiptCommands("TENANT-A",21L); + } @Test void pdaCandidatesUseTheMapperRestrictedToWaitingMeasurementOnly() { WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); @@ -747,14 +759,25 @@ class WarehouseTransferServiceTest { assertEquals(701L, captured.getValue().getPalletId()); assertEquals(5, captured.getValue().getPackageCount()); assertEquals(WarehouseTransferService.ALLOCATED, transfer.getStatus()); - assertEquals("WAIT_OUTBOUND", result.orderStatus()); + assertEquals("INBOUND_COMPLETED", result.orderStatus()); verifyNoInteractions(inventory); + when(inventoryAllocations.lockTransfer("TENANT-A", 21L)).thenReturn(List.of(captured.getValue())); + var outbound = service.outbound("TENANT-A", "WTR-1", "USER-1", new WarehouseTransferRequests.Outbound("OUT-NO-RELABEL", "")); + assertEquals("INBOUND_COMPLETED", outbound.orderStatus()); + assertEquals("WAREHOUSE_TRANSFER:OUTBOUNDED", tailOrder.getSubOrderStatus()); + when(inventory.stockIn(eq("TENANT-A"), eq("USER-1"), any())).thenReturn( + new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false)); + var received = service.receive("TENANT-A", "WTR-1", "USER-1", receiptRequest()); + assertEquals("RECEIVED", received.status()); + assertEquals("INBOUND_COMPLETED", received.orderStatus()); + assertEquals("WAREHOUSE_TRANSFER:RECEIVED", tailOrder.getSubOrderStatus()); } @Test void outboundMovesExactPackagesAllocatePickShipInOrderAndReceiptStocksInSameCount() { WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED); LastlegOrder tailOrder = tailOrder(); + tailOrder.setCurrentStatus("INBOUND_COMPLETED"); WarehouseTransferInventoryAllocation allocation = allocation(); when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); @@ -776,6 +799,7 @@ class WarehouseTransferServiceTest { assertTrue(move.getAllValues().stream().allMatch(value -> value.positionId() == 501L && value.packages() == 5)); assertEquals(WarehouseTransferService.OUTBOUNDED, transfer.getStatus()); assertEquals("SHIPPED", allocation.getStatus()); + assertEquals("INBOUND_COMPLETED", tailOrder.getCurrentStatus()); clearInvocations(inventory); service.outbound("TENANT-A", "WTR-1", "USER-1", @@ -793,10 +817,15 @@ class WarehouseTransferServiceTest { assertEquals(5, stockIn.getValue().packages()); assertEquals("Customer A", stockIn.getValue().customerName()); assertEquals("SKU-TRANSFER", stockIn.getValue().skuCode()); + assertEquals(101L,stockIn.getValue().inboundOrderId()); + assertEquals("INBOUND-101",stockIn.getValue().inboundOrderNo()); + assertEquals("IN-1",stockIn.getValue().inboundCode()); + assertTrue(!stockIn.getValue().inboundOrderId().equals(transfer.getLastlegOrderId())); assertEquals("WTR-1", stockIn.getValue().businessOrderNo()); assertEquals("WAREHOUSE_TRANSFER_RECEIPT:WTR-1:501", stockIn.getValue().idempotencyKey()); assertEquals(WarehouseTransferService.RECEIVED, transfer.getStatus()); assertEquals("RECEIVED", allocation.getStatus()); + assertEquals("INBOUND_COMPLETED", tailOrder.getCurrentStatus()); ArgumentCaptor receipt = ArgumentCaptor.forClass(com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt.class); @@ -807,6 +836,72 @@ class WarehouseTransferServiceTest { verifyNoInteractions(inventory); } + @Test + void partialTransferReceiptKeepsEstablishedMainStateAndOnlyAdvancesItsSubState() { + var transfer = transfer(WarehouseTransferService.OUTBOUNDED); + var tail = tailOrder(); tail.setCurrentStatus("INBOUND_COMPLETED"); + var first = allocation(); first.setStatus("SHIPPED"); + var second = allocation(); second.setId(22L); second.setInventoryPositionId(502L); second.setStatus("SHIPPED"); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(orders.selectById(31L)).thenReturn(tail); + when(inventoryAllocations.lockTransfer("TENANT-A",21L)).thenReturn(List.of(first,second)); + when(inventoryCommands.lockBalance("TENANT-A",601L)).thenReturn(balance(601L,101L,"IN-1")); + when(inventory.stockIn(eq("TENANT-A"),eq("USER-1"),any())).thenReturn( + new InventoryCommandResponse(1L,"STOCK_IN",801L,901L,5,0,0,0,false,false)); + var result=service.receive("TENANT-A","WTR-1","USER-1",receiptRequest()); + assertEquals("PARTIALLY_RECEIVED",result.status()); + assertEquals("INBOUND_COMPLETED",result.orderStatus()); + assertEquals("WAREHOUSE_TRANSFER:PARTIALLY_RECEIVED",tail.getSubOrderStatus()); + assertEquals("SHIPPED",second.getStatus()); + } + + @Test + void twoSourcesOnOneTargetPalletKeepTheirDistinctLegalInboundIdentities() { + var transfer=transfer(WarehouseTransferService.OUTBOUNDED); + var tail=tailOrder();tail.setCurrentStatus("INBOUND_COMPLETED"); + var first=allocation();first.setStatus("SHIPPED"); + var second=allocation();second.setId(52L);second.setInventoryPositionId(502L); + second.setInventoryBalanceId(602L);second.setInboundOrderId(202L);second.setInboundCode("IN-2");second.setStatus("SHIPPED"); + when(transfers.lockNo("TENANT-A","WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A","WTR-1")).thenReturn(transfer); + when(orders.selectById(31L)).thenReturn(tail); + when(inventoryAllocations.lockTransfer("TENANT-A",21L)).thenReturn(List.of(first,second)); + when(inventoryCommands.lockBalance("TENANT-A",601L)).thenReturn(balance(601L,101L,"IN-1")); + when(inventoryCommands.lockBalance("TENANT-A",602L)).thenReturn(balance(602L,202L,"IN-2")); + when(inventory.stockIn(eq("TENANT-A"),eq("USER-1"),any())).thenReturn( + new InventoryCommandResponse(1L,"STOCK_IN",801L,901L,5,0,0,0,false,false), + new InventoryCommandResponse(2L,"STOCK_IN",802L,902L,5,0,0,0,false,false)); + service.receive("TENANT-A","WTR-1","USER-1",receiptRequest()); + var r=receiptRequest(); + service.receive("TENANT-A","WTR-1","USER-1",new WarehouseTransferRequests.Receipt(502L, + r.destinationOverseasWarehouseId(),r.destinationOverseasWarehouseCode(),r.destinationOverseasWarehouseName(), + r.destinationWarehouseId(),r.destinationWarehouseCode(),r.destinationWarehouseName(),r.destinationPalletId(), + r.destinationPalletNo(),r.batchNo(),r.warehouseLocationId(),r.warehouseLocationCode(),"RECEIVE-2",r.remark())); + var captured=ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn.class); + verify(inventory,times(2)).stockIn(eq("TENANT-A"),eq("USER-1"),captured.capture()); + assertEquals(List.of(101L,202L),captured.getAllValues().stream().map(value->value.inboundOrderId()).toList()); + assertEquals(List.of("INBOUND-101","INBOUND-202"),captured.getAllValues().stream().map(value->value.inboundOrderNo()).toList()); + assertEquals(List.of("IN-1","IN-2"),captured.getAllValues().stream().map(value->value.inboundCode()).toList()); + assertTrue(captured.getAllValues().stream().allMatch(value->value.palletId().equals(r.destinationPalletId()) + && value.warehouseLocationId().equals(r.warehouseLocationId()) + && value.overseasWarehouseId().equals(r.destinationOverseasWarehouseId()) + && value.destinationWarehouseId().equals(r.destinationWarehouseId())&&value.businessOrderNo().equals("WTR-1"))); + assertEquals("RECEIVED",transfer.getStatus());assertEquals("INBOUND_COMPLETED",tail.getCurrentStatus()); + } + + @Test + void receiptRejectsMissingSourceInboundIdentityInsteadOfSubstitutingTheTransferTail() { + var transfer=transfer(WarehouseTransferService.OUTBOUNDED); + var allocated=allocation();allocated.setStatus("SHIPPED"); + var source=balance(601L,101L,"IN-1");source.setInboundOrderNo(null); + when(transfers.lockNo("TENANT-A","WTR-1")).thenReturn(transfer); + when(inventoryAllocations.lockTransfer("TENANT-A",21L)).thenReturn(List.of(allocated)); + when(inventoryCommands.lockBalance("TENANT-A",601L)).thenReturn(source); + assertThrows(ResponseStatusException.class,()->service.receive("TENANT-A","WTR-1","USER-1",receiptRequest())); + verifyNoInteractions(inventory);assertEquals("SHIPPED",allocated.getStatus()); + } + @Test void requiredRelabelCannotShipReservedOriginalStockBeforeCustomerConfirmation() { WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED); @@ -1070,6 +1165,7 @@ class WarehouseTransferServiceTest { private static InventoryBalance balance(long id, long inboundOrderId, String inboundCode) { InventoryBalance balance = new InventoryBalance(); balance.setId(id); balance.setCustomerId("TENANT-A"); balance.setInboundOrderId(inboundOrderId); + balance.setInboundOrderNo("INBOUND-" + inboundOrderId); balance.setCustomerName("Customer A"); balance.setInboundCode(inboundCode); balance.setSkuCode("SKU-TRANSFER"); return balance;