From 5368da03d823dd0117b7e2505d89f79041b24125 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 19 Oct 2026 17:39:54 +0800 Subject: [PATCH] feat(order): accept optional courier tracking on customer drafts --- .../com/baiyue/order/dto/OrderRequests.java | 12 +++-- .../service/OrderApplicationService.java | 5 ++ .../PlatformLegacyOrderImportService.java | 2 +- .../src/main/resources/openapi/order-v1.yaml | 1 + .../MerchantOrderCommandBindingTest.java | 18 +++++++ .../OrderApplicationServiceStateTest.java | 52 +++++++++++++++++++ 6 files changed, 84 insertions(+), 6 deletions(-) diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java index 992ececd6..9be3cb9fd 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -81,7 +81,9 @@ public final class OrderRequests { @Schema(description = "Selected customer pickup address ID; required for PICKUP / 已选客户取货地址 ID;PICKUP 时必填") @Min(1) Long senderAddressId, @Schema(description = "Selected private destination address ID; required for PRIVATE / 已选私人目的仓地址 ID;PRIVATE 时必填") - @Min(1) Long privateDestinationAddressId + @Min(1) Long privateDestinationAddressId, + @Schema(description = "Optional customer courier tracking number for SELF_EXPRESS / 快递自提选填快递单号;其他取货方式不保存", maxLength = 128) + @Size(max = 128) String expressTrackingNo ) { public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm, String destinationCountryCode, Long destinationWarehouseId, @@ -98,7 +100,7 @@ public final class OrderRequests { sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId, receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode, - shipmentId, referenceId, inboundCode, fulfillmentType, null, null, null); + shipmentId, referenceId, inboundCode, fulfillmentType, null, null, null, null); } public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm, @@ -116,7 +118,7 @@ public final class OrderRequests { sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId, receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode, - shipmentId, referenceId, inboundCode, null, null, null, null); + shipmentId, referenceId, inboundCode, null, null, null, null, null); } public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm, @@ -133,7 +135,7 @@ public final class OrderRequests { sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId, receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode, - shipmentId, referenceId, null, null, null, null, null); + shipmentId, referenceId, null, null, null, null, null, null); } public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm, @@ -150,7 +152,7 @@ public final class OrderRequests { sender, receiver, cargoes, pickupMethod, null, receivingWarehouseId, receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode, - shipmentId, referenceId, null, null, null, null, null); + shipmentId, referenceId, null, null, null, null, null, null); } @AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空") diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 13c7dc282..39ccc4a2e 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -642,6 +642,7 @@ public class OrderApplicationService { default -> false; }; } + // 保存客户下单资料;快递单号仅属于快递自提,切换方式时清空避免误带。 private void apply(OrderEntity order, long tenantId, OrderRequests.SaveRequest request, OrderMasterDataGuard.Snapshots snapshots, AddressSnapshots addresses, boolean allowLegacyInboundCode) { @@ -662,6 +663,10 @@ public class OrderApplicationService { order.setExpectedArrivalAt(request.expectedShipDate() == null ? null : request.expectedShipDate().atStartOfDay()); order.setCustomerRemark(request.customerRemark()); order.setPickupMethod(request.pickupMethod()); + if (order.getStatus() == OrderStatus.DRAFT || order.getStatus() == OrderStatus.REJECTED) { + order.setExpressTrackingNo("SELF_EXPRESS".equals(request.pickupMethod()) + ? normalizeRemark(request.expressTrackingNo()) : null); + } order.setRequestedPickupAt("PICKUP".equals(request.pickupMethod()) ? request.requestedPickupAt() : null); order.setReceivingWarehouseId(request.receivingWarehouseId()); order.setReceivingWarehouseName(snapshots.receiving().warehouse().warehouseName()); diff --git a/order/src/main/java/com/baiyue/order/service/PlatformLegacyOrderImportService.java b/order/src/main/java/com/baiyue/order/service/PlatformLegacyOrderImportService.java index 510c75797..a3626c0ae 100644 --- a/order/src/main/java/com/baiyue/order/service/PlatformLegacyOrderImportService.java +++ b/order/src/main/java/com/baiyue/order/service/PlatformLegacyOrderImportService.java @@ -162,7 +162,7 @@ public class PlatformLegacyOrderImportService { "买单".equals(required(row, 5, "买单/退税")) ? "BUY_DECLARATION" : "TAX_REFUND", channel.channelName().contains("包税") && !channel.channelName().contains("不包税") ? "TAX_INCLUDED" : "TAX_EXCLUDED", "LCL", false, destinationType, amazonCode, shipmentId, referenceId, inboundCode, fulfillmentType, - "ONE_CLICK_DROPSHIP".equals(fulfillmentType) ? referenceId : null, null, null); + "ONE_CLICK_DROPSHIP".equals(fulfillmentType) ? referenceId : null, null, null, null); } private Customer fixedCustomer() { diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index c7ed83a02..ea2281f74 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -1451,6 +1451,7 @@ components: pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'} senderAddressId: {type: string, pattern: '^[1-9][0-9]*$', description: PICKUP 时必填;必须是本客户 PICKUP 地址} privateDestinationAddressId: {type: string, pattern: '^[1-9][0-9]*$', description: PRIVATE 时必填;必须是本客户 PRIVATE_DESTINATION 地址} + expressTrackingNo: {type: string, nullable: true, maxLength: 128, description: 快递自提(SELF_EXPRESS)时选填;留空清空单号,其他取货方式不保存。客户仅能在下单或编辑草稿时填写} customsInspection: {type: boolean, nullable: true, description: 非木制品可空;任一货物 woodenProduct=true 时必须为 true} cargoes: type: array diff --git a/order/src/test/java/com/baiyue/order/controller/MerchantOrderCommandBindingTest.java b/order/src/test/java/com/baiyue/order/controller/MerchantOrderCommandBindingTest.java index 2473d5bef..ad2340653 100644 --- a/order/src/test/java/com/baiyue/order/controller/MerchantOrderCommandBindingTest.java +++ b/order/src/test/java/com/baiyue/order/controller/MerchantOrderCommandBindingTest.java @@ -52,6 +52,24 @@ class MerchantOrderCommandBindingTest { }).build(); } + /** 客户创建入口绑定选填单号,并拒绝超过持久化长度的输入。 */ + @Test + void courierTrackingIsOptionalAndLengthBoundedOnCreate() throws Exception { + String createPath = "/api/merchant/v1/orders/first-leg"; + for (String tracking : new String[] {null, "", "SF123456789"}) { + String body = tracking == null ? DRAFT : DRAFT.replace("\"customerName\"", "\"expressTrackingNo\":\"" + tracking + "\",\"customerName\""); + mvc.perform(post(createPath).header("Idempotency-Key", "courier-create").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("200")); + verify(service).createDraft(eq(11L), eq(7L), eq("operator"), + argThat(request -> java.util.Objects.equals(tracking, request.expressTrackingNo())), eq("courier-create")); + } + clearInvocations(service); + String invalid = DRAFT.replace("\"customerName\"", "\"expressTrackingNo\":\"" + "A".repeat(129) + "\",\"customerName\""); + mvc.perform(post(createPath).header("Idempotency-Key", "courier-create").contentType(MediaType.APPLICATION_JSON).content(invalid)) + .andExpect(status().isBadRequest()); + verifyNoInteractions(service); + } + @Test void platformEditRequiresVersionEnvelopeAndKey() throws Exception { String path = "/api/platform/v1/orders/first-leg/1?tenantId=11"; diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 2de0dceaa..d926c26fb 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -111,6 +111,58 @@ class OrderApplicationServiceStateTest { && order.getInboundCode() == null && "STANDARD".equals(order.getFulfillmentType()))); } + /** 快递草稿支持填写或留空单号,非快递方式不能携带单号。 */ + @Test + void courierDraftPersistsOptionalTrackingNumber() { + for (String pickupMethod : List.of("SELF_EXPRESS", "SELF_TRUCK")) { + for (String tracking : List.of(" SF123456789 ", "", " ")) { + var json = new ObjectMapper().valueToTree(request(null, pickupMethod, null, null, "OVERSEAS")); + ((com.fasterxml.jackson.databind.node.ObjectNode) json).put("expressTrackingNo", tracking); + ((com.fasterxml.jackson.databind.node.ObjectNode) json).remove("carrierSnapshotComplete"); + var draft = new ObjectMapper().convertValue(json, OrderRequests.SaveRequest.class); + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + when(masterData.validate(10001L, draft)).thenReturn(snapshots()); + when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1); + + service(repository, mock(OrderEligibilityGuard.class), masterData) + .createDraft(10001L, 2L, draft, "courier-draft"); + + String expected = "SELF_EXPRESS".equals(pickupMethod) && !tracking.isBlank() + ? tracking.trim() : null; + verify(repository).insertOrder(argThat(saved -> java.util.Objects.equals(expected, saved.getExpressTrackingNo()))); + } + } + } + + /** 修改草稿允许更换、清空单号,切换交货方式后不保留旧单号。 */ + @Test + void editingCourierDraftReplacesOrClearsTrackingNumber() { + for (String pickupMethod : List.of("SELF_EXPRESS", "SELF_TRUCK")) { + for (String tracking : List.of(" SF-NEW ", "")) { + var json = new ObjectMapper().valueToTree(request(null, pickupMethod, null, null, "OVERSEAS")); + ((com.fasterxml.jackson.databind.node.ObjectNode) json).put("expressTrackingNo", tracking); + ((com.fasterxml.jackson.databind.node.ObjectNode) json).remove("carrierSnapshotComplete"); + var draft = new ObjectMapper().convertValue(json, OrderRequests.SaveRequest.class); + var order = persistedOrder(OrderStatus.DRAFT); + order.setExpressTrackingNo("SF-OLD"); + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + when(repository.findOwned(10001L, 1L)).thenReturn(order); + when(repository.saveOrder(10001L, order)).thenReturn(1); + when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1); + when(masterData.validate(10001L, draft)).thenReturn(snapshots()); + + service(repository, mock(OrderEligibilityGuard.class), masterData).updateDraft(10001L, 2L, 1L, + new OrderRequests.DraftUpdateRequest(0, draft), "courier-edit"); + + assertThat(order.getExpressTrackingNo()).isEqualTo( + "SELF_EXPRESS".equals(pickupMethod) && !tracking.isBlank() ? tracking.trim() : null); + verify(repository).saveOrder(10001L, order); + } + } + } + @Test void creatingDraftPreservesProductImageDimensionsAndRemark() { OrderRepository repository = mock(OrderRepository.class);