diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java index bb9c3b274..506f7c6d6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -1,5 +1,5 @@ package com.baiyue.firstleg.dto; -import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.util.List; +import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; public final class SmartLoadingDtos { private SmartLoadingDtos(){} public record ChannelSummary(String channelCode, String channelName, long candidateInboundCount, long availablePackageCount, long availableContainerCount) {} @@ -9,12 +9,12 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){} Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal averageUnitVolumeCbm, BigDecimal availableVolumeCbm, Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes, - Boolean locked, String blockReason) {} + Boolean locked, String blockReason, String orderStatusSnapshot, LocalDateTime expectedArrivalAt) {} public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode, Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm, Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes, - Boolean locked, String blockReason) {} + Boolean locked, String blockReason, String orderStatusSnapshot, LocalDateTime expectedArrivalAt) {} public record AssignRequest(@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId, @NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {} public record AssignInboundRequest(@NotNull @Positive Long inboundId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index 3d4729b41..d6610dbbd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -15,6 +15,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba @Select(""" SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId, o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode, + o.order_status_snapshot orderStatusSnapshot,o.expected_arrival_at expectedArrivalAt, o.warehouse_id warehouseId,o.channel_code channelCode,o.channel_name channelName,o.destination_warehouse_id destinationWarehouseId, o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName, o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName, @@ -64,6 +65,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba @Select(""" SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId, o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode, + o.order_status_snapshot orderStatusSnapshot,o.expected_arrival_at expectedArrivalAt, o.warehouse_id warehouseId,o.channel_code channelCode,o.channel_name channelName,o.destination_warehouse_id destinationWarehouseId, o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName, o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 9072bb1a1..399c08cc2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -113,7 +113,8 @@ public class SmartLoadingService { first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(), first.customerName(), goods, packages, first.unitGrossWeightKg(), first.unitNetWeightKg(), averageVolume, volume, first.coLoadGroupId(), first.groupNo(), first.memberCount(), - first.groupTotalBoxes(), first.locked(), first.blockReason()); + first.groupTotalBoxes(), first.locked(), first.blockReason(), first.orderStatusSnapshot(), + first.expectedArrivalAt()); } public Plan draftByBooking(long bookingId) { @@ -563,7 +564,8 @@ public class SmartLoadingService { candidate.customerName(), candidate.goodsName(), candidate.availablePackageCount(), candidate.unitGrossWeightKg(), candidate.unitNetWeightKg(), candidate.unitVolumeCbm(), group.groupId(), group.groupNo(), group.members().size(), group.fullPackageCount(), - group.lockedPlanId() != null, blocks.get(group.groupId())); + group.lockedPlanId() != null, blocks.get(group.groupId()), candidate.orderStatusSnapshot(), + candidate.expectedArrivalAt()); }).toList(); } diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 79dcf97c0..877b07eab 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -409,6 +409,7 @@ paths: description: >- 需要 platform:firstleg:smart-loading:list 权限。每个入库单/入仓码只返回一行; 平均单箱毛重、净重按整票实测重量除以总箱数,平均单箱体积按剩余规格数量加权。 + 响应携带订单状态快照 orderStatusSnapshot 与预计到货时间 expectedArrivalAt,供平台筛选候选入仓码; 这些数据用于数量级计划,不表示具体箱号已核验归属。 parameters: - {name: bookingId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index 6cd5052e8..83114096f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -13,7 +13,7 @@ class SmartLoadingCandidateMapperContractTest { @Test void candidateHasOnlyTheCanonicalMybatisConstructor() { assertThat(Candidate.class.getDeclaredConstructors()).singleElement() - .satisfies(constructor -> assertThat(constructor.getParameterCount()).isEqualTo(24)); + .satisfies(constructor -> assertThat(constructor.getParameterCount()).isEqualTo(26)); } @Test @@ -23,6 +23,8 @@ class SmartLoadingCandidateMapperContractTest { assertThat(sql) .contains("o.destination_warehouse_id destinationWarehouseId") + .contains("o.order_status_snapshot orderStatusSnapshot") + .contains("o.expected_arrival_at expectedArrivalAt") .contains("m.status='APPROVED'") .contains("SELECT MAX(m2.id)") .contains("m2.status='APPROVED'") @@ -51,6 +53,8 @@ class SmartLoadingCandidateMapperContractTest { assertThat(sql) .contains("o.destination_warehouse_id destinationWarehouseId") + .contains("o.order_status_snapshot orderStatusSnapshot") + .contains("o.expected_arrival_at expectedArrivalAt") .contains("m.status='APPROVED'") .contains("SELECT MAX(m2.id)") .contains("o.warehouse_id=#{warehouseId}") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 18ac003b2..16b7e7409 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -122,11 +122,11 @@ class SmartLoadingServiceTest { Candidate first = new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L, "FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "服装", 3, new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.2"), - null, null, null, null, null, null); + null, null, null, null, null, null, "WAIT_SPLIT", LocalDateTime.parse("2026-09-10T09:30:00")); Candidate second = new Candidate(9L, 10L, 21L, 30L, "O", "I", 100L, "FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "鞋类", 7, new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5"), - null, null, null, null, null, null); + null, null, null, null, null, null, "WAIT_SPLIT", LocalDateTime.parse("2026-09-10T09:30:00")); when(repository.booking(99L)).thenReturn(booking()); when(repository.candidates(100L)).thenReturn(List.of(first, second)); @@ -139,6 +139,8 @@ class SmartLoadingServiceTest { assertThat(candidate.averageUnitVolumeCbm()).isEqualByComparingTo("0.410000"); assertThat(candidate.availableVolumeCbm()).isEqualByComparingTo("4.1000"); assertThat(candidate.unitGrossWeightKg()).isEqualByComparingTo("2"); + assertThat(candidate.orderStatusSnapshot()).isEqualTo("WAIT_SPLIT"); + assertThat(candidate.expectedArrivalAt()).isEqualTo(LocalDateTime.parse("2026-09-10T09:30:00")); }); } @@ -440,15 +442,15 @@ class SmartLoadingServiceTest { Candidate first = new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L, "FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "A", 30, new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.2"), - null, null, null, null, null, null); + null, null, null, null, null, null, null, null); Candidate second = new Candidate(9L, 10L, 21L, 30L, "O", "I", 100L, "FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "B", 40, new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5"), - null, null, null, null, null, null); + null, null, null, null, null, null, null, null); Candidate third = new Candidate(9L, 10L, 22L, 30L, "O", "I", 100L, "FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "C", 30, new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.8"), - null, null, null, null, null, null); + null, null, null, null, null, null, null, null); var result = service.proportionalAllocations(List.of(first, second, third), 60); @@ -1109,7 +1111,7 @@ class SmartLoadingServiceTest { return new Candidate(tenantId, inboundId, detailId, orderId, "O" + orderId, "I" + inboundId, 100L, "FBA", "FBA渠道", 200L, destinationCode, "FBA仓", "客户", "货物", boxes, new BigDecimal("2"), new BigDecimal("1.5"), - new BigDecimal("0.5"), null, null, null, null, null, null); + new BigDecimal("0.5"), null, null, null, null, null, null, null, null); } private static Candidate withChannel(Candidate value, String channelCode, String channelName) { @@ -1118,7 +1120,8 @@ class SmartLoadingServiceTest { value.destinationWarehouseId(), value.destinationWarehouseCode(), value.destinationWarehouseName(), value.customerName(), value.goodsName(), value.availablePackageCount(), value.unitGrossWeightKg(), value.unitNetWeightKg(), value.unitVolumeCbm(), value.coLoadGroupId(), value.groupNo(), - value.memberCount(), value.groupTotalBoxes(), value.locked(), value.blockReason()); + value.memberCount(), value.groupTotalBoxes(), value.locked(), value.blockReason(), + value.orderStatusSnapshot(), value.expectedArrivalAt()); } private static ChannelRow channel(String channelCode, String channelName) { diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java index e61647e36..9e1a1b462 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.RequestHeader; import java.time.LocalDateTime; import java.util.List; +import com.baiyue.order.dto.OrderDeclarationDtos; /** 订单审核事件投递到头程入库域的内部客户端。 */ @FeignClient(name = "firstleg-service", contextId = "orderFirstlegInboundClient", @@ -31,6 +32,10 @@ public interface FirstlegInboundClient { @RequestHeader("X-Internal-Service-Token") String token, @RequestBody InboundReferenceRequest request); + @PostMapping("/order-declaration-updated") + ApiResponse updateDeclaration(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody OrderDeclarationDtos.UpdatedEvent event); + record InboundReferenceRequest(long tenantId, List orderIds) { } diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java index 705ecf92e..0912d7618 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java @@ -7,7 +7,9 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.order.dto.UnifiedOrderQuery; import com.baiyue.order.dto.UnifiedOrderResponse; import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.dto.OrderDeclarationDtos; import com.baiyue.order.service.OrderApplicationService; +import com.baiyue.order.service.OrderDeclarationUpdateService; import com.baiyue.order.service.OrderScopeGuard; import com.baiyue.order.service.UnifiedOrderQueryService; import com.baiyue.order.service.OrderFulfillmentSplitService; @@ -25,6 +27,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.PutMapping; import java.util.List; /** Platform product-level order query. Legacy first-leg paths remain available. */ @@ -35,6 +38,7 @@ public class PlatformUnifiedOrderController { @Resource private UnifiedOrderQueryService service; @Resource private OrderApplicationService orderService; @Resource private OrderFulfillmentSplitService splitService; + @Resource private OrderDeclarationUpdateService declarationUpdates; @GetMapping @PreAuthorize("hasAuthority('platform:order:list')") @@ -58,6 +62,22 @@ public class PlatformUnifiedOrderController { return ApiResponse.success(service.platformDetail(OrderScopeGuard.platform(user, tenantId), orderId)); } + @PutMapping("/{orderId}/declaration") + @PreAuthorize("hasAuthority('platform:order:declaration:edit')") + @BusinessAudit(event = "ORDER_DECLARATION_UPDATE", resourceType = "FIRST_LEG_ORDER", + resourceId = "#orderId", tenantId = "#tenantId", description = "平台修改客户原始申报资料") + @Operation(summary = "版本化修改客户原始申报资料", + description = "只修改客户申报快照;不修改客户、收货仓、入仓码、实测、PDA、审核或计费事实。") + public ApiResponse updateDeclaration( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, + @PathVariable long orderId, + @Valid @RequestBody OrderDeclarationDtos.UpdateRequest request) { + long scopedTenant = OrderScopeGuard.platform(user, tenantId); + return ApiResponse.success(declarationUpdates.update(scopedTenant, orderId, user.userId(), + user.username(), request)); + } + @GetMapping("/{orderId}/fulfillment-tree") @PreAuthorize("hasAuthority('platform:order:list')") @Operation(summary = "父子订单与柜次数量摘要") diff --git a/order/src/main/java/com/baiyue/order/dto/OrderDeclarationDtos.java b/order/src/main/java/com/baiyue/order/dto/OrderDeclarationDtos.java new file mode 100644 index 000000000..d05bfdfee --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/OrderDeclarationDtos.java @@ -0,0 +1,69 @@ +package com.baiyue.order.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** 平台修改客户原始申报资料的独立版本化契约。 */ +public final class OrderDeclarationDtos { + private OrderDeclarationDtos() {} + + public record UpdateRequest( + @NotNull @Min(0) Integer expectedVersion, + @Size(max = 1000) String customerRemark, + LocalDateTime expectedArrivalAt, + @Size(max = 64) String channelCode, + @Size(max = 128) String channelName, + @NotEmpty @Size(max = 500) List<@Valid Cargo> cargoes + ) {} + + /** itemNo 是订单内稳定业务行号;不以货物数据库 ID 作为编辑契约。 */ + public record Cargo( + @NotNull @Min(1) @Max(500) Integer itemNo, + @NotBlank @Size(max = 128) String cargoName, + @Size(max = 128) String cargoNameEn, + @Size(max = 64) String skuNo, + @Size(max = 64) String model, + @NotNull @Min(1) Integer packageCount, + @NotNull @Min(1) Integer totalQuantity, + @NotNull @DecimalMin("0.01") BigDecimal lengthCm, + @NotNull @DecimalMin("0.01") BigDecimal widthCm, + @NotNull @DecimalMin("0.01") BigDecimal heightCm, + @NotNull @DecimalMin("0.001") BigDecimal grossWeightKg, + @NotNull @DecimalMin("0.0001") BigDecimal volumeCbm, + @DecimalMin("0.001") BigDecimal netWeightKg, + @Size(max = 20) String hsCode, + @Size(max = 20) String customsHsCode, + @Min(1) Integer unitsPerPackage, + @DecimalMin("0.00") BigDecimal unitDeclaredValue, + @DecimalMin("0.00") BigDecimal declaredValue, + @Pattern(regexp = "[A-Z]{3}") String currency, + @Size(max = 128) String material, + @Size(max = 128) String usage, + @Size(max = 64) String brand, + @Size(max = 500) String cargoRemark + ) {} + + public record UpdateResponse(String orderNo, String inboundCode, int previousVersion, int version, + boolean channelChangedBefore, boolean channelChanged, + List changes) {} + + public record FieldChange(Integer itemNo, String field, Object beforeValue, Object afterValue) {} + + /** Order -> Firstleg 版本化 Outbox 快照;消费者须按 eventId 幂等并按 version 顺序应用。 */ + public record UpdatedEvent(int schemaVersion, String eventId, long tenantId, long orderId, + String orderNo, String inboundCode, int version, long operatorId, + LocalDateTime occurredAt, String customerRemark, + LocalDateTime expectedArrivalAt, String channelCode, String channelName, + boolean channelChanged, List cargoes, List changes) {} +} diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java index 4aded5a9c..70ce132e3 100644 --- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java @@ -15,12 +15,12 @@ public final class UnifiedOrderResponse { Long orderId, Long tenantId, String orderNo, String mainStatus, String nextAction, OrderRelationSummary relation, OrderStatus internalOrderStatus, - Long customerId, String customerName, String channelCode, String channelName, + Long customerId, String customerName, String channelCode, String channelName, Boolean channelChanged, Long receivingWarehouseId, String receivingWarehouseName, Long destinationWarehouseId, String destinationWarehouseName, Long inboundId, Integer inboundVersion, String inboundCode, LocalDate expectedShipDate, Integer totalPackageCount, String recommendedPalletType, String locationCodes, - BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm, + BigDecimal expectedVolumeCbm, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm, LocalDateTime expectedArrivalAt, String feeReviewStatus, String companyName, String businessType, String pickupMethod, String taxMode, String containerMode, Boolean customsInspection, diff --git a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java index 0c8fceec5..79782f8d4 100644 --- a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java +++ b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java @@ -31,6 +31,7 @@ public class OrderEntity extends BaseEntity { private Long destinationWarehouseId; private String destinationWarehouseName; private LocalDate expectedShipDate; + private LocalDateTime expectedArrivalAt; private String customerRemark; private String pickupMethod; private LocalDateTime requestedPickupAt; @@ -40,6 +41,7 @@ public class OrderEntity extends BaseEntity { private String receivingTime; private String channelCode; private String channelName; + private Boolean channelChanged; private Long carrierId; private String carrierName; private String taxMode; diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index 3a2da6fa2..0bf9cbe27 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -54,6 +54,56 @@ public class OrderRepository { .eq(OrderEntity::getId, order.getId()) .eq(OrderEntity::getTenantId, tenantId)); } + public int updateDeclarationHeader(long tenantId, long orderId, int expectedVersion, long operatorId, + String customerRemark, LocalDateTime expectedArrivalAt, + String channelCode, String channelName, boolean channelChanged) { + return orderMapper.update(null, new LambdaUpdateWrapper() + .eq(OrderEntity::getTenantId, tenantId) + .eq(OrderEntity::getId, orderId) + .eq(OrderEntity::getVersion, expectedVersion) + .eq(OrderEntity::getDeleted, false) + .set(OrderEntity::getCustomerRemark, customerRemark) + .set(OrderEntity::getExpectedArrivalAt, expectedArrivalAt) + .set(OrderEntity::getChannelCode, channelCode) + .set(OrderEntity::getChannelName, channelName) + .set(OrderEntity::getChannelChanged, channelChanged) + .set(OrderEntity::getUpdatedBy, operatorId) + .set(OrderEntity::getUpdatedAt, LocalDateTime.now()) + .setSql("version = version + 1")); + } + public int updateDeclarationCargo(long tenantId, long orderId, OrderCargoEntity cargo) { + return cargoMapper.update(null, new LambdaUpdateWrapper() + .eq(OrderCargoEntity::getTenantId, tenantId) + .eq(OrderCargoEntity::getOrderId, orderId) + .eq(OrderCargoEntity::getId, cargo.getId()) + .eq(OrderCargoEntity::getVersion, cargo.getVersion()) + .eq(OrderCargoEntity::getDeleted, false) + .set(OrderCargoEntity::getCargoName, cargo.getCargoName()) + .set(OrderCargoEntity::getCargoNameEn, cargo.getCargoNameEn()) + .set(OrderCargoEntity::getSkuNo, cargo.getSkuNo()) + .set(OrderCargoEntity::getModel, cargo.getModel()) + .set(OrderCargoEntity::getPackageCount, cargo.getPackageCount()) + .set(OrderCargoEntity::getTotalQuantity, cargo.getTotalQuantity()) + .set(OrderCargoEntity::getLengthCm, cargo.getLengthCm()) + .set(OrderCargoEntity::getWidthCm, cargo.getWidthCm()) + .set(OrderCargoEntity::getHeightCm, cargo.getHeightCm()) + .set(OrderCargoEntity::getGrossWeightKg, cargo.getGrossWeightKg()) + .set(OrderCargoEntity::getVolumeCbm, cargo.getVolumeCbm()) + .set(OrderCargoEntity::getNetWeightKg, cargo.getNetWeightKg()) + .set(OrderCargoEntity::getHsCode, cargo.getHsCode()) + .set(OrderCargoEntity::getCustomsHsCode, cargo.getCustomsHsCode()) + .set(OrderCargoEntity::getUnitsPerPackage, cargo.getUnitsPerPackage()) + .set(OrderCargoEntity::getUnitDeclaredValue, cargo.getUnitDeclaredValue()) + .set(OrderCargoEntity::getDeclaredValue, cargo.getDeclaredValue()) + .set(OrderCargoEntity::getCurrency, cargo.getCurrency()) + .set(OrderCargoEntity::getMaterial, cargo.getMaterial()) + .set(OrderCargoEntity::getProductUsage, cargo.getProductUsage()) + .set(OrderCargoEntity::getBrand, cargo.getBrand()) + .set(OrderCargoEntity::getCargoRemark, cargo.getCargoRemark()) + .set(OrderCargoEntity::getUpdatedBy, cargo.getUpdatedBy()) + .set(OrderCargoEntity::getUpdatedAt, cargo.getUpdatedAt()) + .setSql("version = version + 1")); + } public void insertCargo(OrderCargoEntity cargo) { cargoMapper.insert(cargo); } public void insertParty(OrderPartyEntity party) { partyMapper.insert(party); } public void insertNote(OrderInternalNoteEntity note) { noteMapper.insert(note); } diff --git a/order/src/main/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderService.java b/order/src/main/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderService.java index 7597e0856..30eff5d11 100644 --- a/order/src/main/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderService.java +++ b/order/src/main/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderService.java @@ -49,9 +49,12 @@ public class DomesticWarehouseOrderExportProviderService { column("mainStatus", "订单主状态", ExportColumnType.STRING, 16), column("customerName", "客户", ExportColumnType.STRING, 24), column("channelName", "渠道", ExportColumnType.STRING, 20), + column("channelChanged", "曾变更渠道", ExportColumnType.BOOLEAN, 12), column("receivingWarehouseName", "定位仓库", ExportColumnType.STRING, 20), column("destinationWarehouseName", "交货仓", ExportColumnType.STRING, 20), column("expectedShipDate", "预计出运日期", ExportColumnType.DATE, 14), + column("expectedArrivalAt", "预计到货时间", ExportColumnType.DATETIME, 21), + column("expectedVolumeCbm", "预计体积(m³)", ExportColumnType.DECIMAL, 16), column("totalPackageCount", "箱数", ExportColumnType.INTEGER, 12), column("totalQuantity", "产品数量", ExportColumnType.INTEGER, 12), column("actualGrossWeightKg", "实测毛重(kg)", ExportColumnType.DECIMAL, 16), @@ -124,8 +127,9 @@ public class DomesticWarehouseOrderExportProviderService { Map row = new LinkedHashMap<>(); row.put("inboundCode", value.inboundCode()); row.put("orderNo", value.orderNo()); row.put("mainStatus", value.mainStatus()); row.put("customerName", value.customerName()); - row.put("channelName", value.channelName()); row.put("receivingWarehouseName", value.receivingWarehouseName()); + row.put("channelName", value.channelName()); row.put("channelChanged", value.channelChanged()); row.put("receivingWarehouseName", value.receivingWarehouseName()); row.put("destinationWarehouseName", value.destinationWarehouseName()); row.put("expectedShipDate", value.expectedShipDate()); + row.put("expectedArrivalAt", value.expectedArrivalAt()); row.put("expectedVolumeCbm", value.expectedVolumeCbm()); row.put("totalPackageCount", value.totalPackageCount()); row.put("totalQuantity", value.totalQuantity()); row.put("actualGrossWeightKg", value.actualGrossWeightKg()); row.put("actualVolumeCbm", value.actualVolumeCbm()); row.put("chargeableWeightKg", value.chargeableWeightKg()); row.put("chargeableVolumeCbm", value.chargeableVolumeCbm()); 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 48adfb3e5..92a842d62 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -466,7 +466,9 @@ public class OrderApplicationService { ? addresses.privateDestination().getAddressName() : snapshots.destination() == null ? request.destinationWarehouseName() : snapshots.destination().warehouse().warehouseName()); - order.setExpectedShipDate(request.expectedShipDate()); order.setCustomerRemark(request.customerRemark()); + order.setExpectedShipDate(request.expectedShipDate()); + order.setExpectedArrivalAt(request.expectedShipDate() == null ? null : request.expectedShipDate().atStartOfDay()); + order.setCustomerRemark(request.customerRemark()); order.setPickupMethod(request.pickupMethod()); order.setRequestedPickupAt("PICKUP".equals(request.pickupMethod()) ? request.requestedPickupAt() : null); order.setReceivingWarehouseId(request.receivingWarehouseId()); @@ -477,6 +479,7 @@ public class OrderApplicationService { // keep one canonical code so later estimation and audit lookups are stable. order.setChannelCode(request.channelCode().trim().toUpperCase(Locale.ROOT)); order.setChannelName(request.channelName()); + if (order.getChannelChanged() == null) order.setChannelChanged(false); order.setCarrierId(request.carrierId()); order.setCarrierName(request.carrierId() == null || !StringUtils.hasText(request.carrierName()) ? null : request.carrierName().trim()); @@ -671,7 +674,8 @@ public class OrderApplicationService { order.getTenantId(), order.getId(), order.getOrderNo(), order.getInboundCode(), order.getCustomerId(), order.getCustomerName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getDestinationWarehouseId(), - order.getDestinationWarehouseName(), order.getExpectedShipDate() == null ? null : order.getExpectedShipDate().atStartOfDay(), + order.getDestinationWarehouseName(), order.getExpectedArrivalAt() != null ? order.getExpectedArrivalAt() + : order.getExpectedShipDate() == null ? null : order.getExpectedShipDate().atStartOfDay(), order.getDestinationType() + "_" + order.getTransportMode(), order.getChannelCode(), order.getChannelName(), order.getCarrierId(), order.getCarrierName(), order.getTaxMode(), order.getPricingTaxMode(), diff --git a/order/src/main/java/com/baiyue/order/service/OrderDeclarationUpdateService.java b/order/src/main/java/com/baiyue/order/service/OrderDeclarationUpdateService.java new file mode 100644 index 000000000..5e048ae89 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/OrderDeclarationUpdateService.java @@ -0,0 +1,179 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.dto.OrderDeclarationDtos; +import com.baiyue.order.entity.OrderCargoEntity; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderStatus; +import com.baiyue.order.entity.OrderStatusHistoryEntity; +import com.baiyue.order.entity.OutboxEventEntity; +import com.baiyue.order.repository.OrderRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +/** 平台版本化修改客户申报快照;绝不写入仓库实测、PDA、测量审核或计费事实。 */ +@Service +public class OrderDeclarationUpdateService { + private static final Set EDITABLE = Set.of( + OrderStatus.DRAFT, OrderStatus.REJECTED, OrderStatus.SUBMITTED, OrderStatus.APPROVED); + + @Resource private OrderRepository repository; + @Resource private OrderPricingChannelGuard pricingChannelGuard; + @Resource private UnifiedOrderQueryService unifiedOrders; + @Resource private ObjectMapper json; + + @Transactional + public OrderDeclarationDtos.UpdateResponse update(long tenantId, long orderId, long operatorId, + String operatorName, + OrderDeclarationDtos.UpdateRequest request) { + OrderEntity order = repository.findOwned(tenantId, orderId); + if (order == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在"); + if (!EDITABLE.contains(order.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "订单已进入不可逆履约状态,不能修改客户申报资料"); + } + if (!Objects.equals(order.getVersion(), request.expectedVersion())) { + throw new BizException(ResultCode.CONFLICT, "订单版本已变化,请刷新详情后重试"); + } + + List current = repository.findCargo(tenantId, orderId); + Map byItemNo = new HashMap<>(); + current.forEach(item -> byItemNo.put(item.getItemNo(), item)); + Set requestedItems = new HashSet<>(); + request.cargoes().forEach(item -> requestedItems.add(item.itemNo())); + if (requestedItems.size() != request.cargoes().size() || !requestedItems.equals(byItemNo.keySet())) { + throw new BizException(ResultCode.VALIDATION_FAILED, "货物行必须与当前订单完整一致,不允许新增、删除或重复行"); + } + + String channelCode = value(request.channelCode(), order.getChannelCode()); + String channelName = value(request.channelName(), order.getChannelName()); + if (!StringUtils.hasText(channelCode) || !StringUtils.hasText(channelName)) { + throw new BizException(ResultCode.VALIDATION_FAILED, "渠道编码和名称必须同时填写"); + } + channelCode = channelCode.toUpperCase(Locale.ROOT); + boolean channelChangedNow = !Objects.equals(channelCode, order.getChannelCode()) + || !Objects.equals(channelName, order.getChannelName()); + if (channelChangedNow) { + pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(), channelCode, + order.getDestinationType()); + } + + if (order.getStatus() == OrderStatus.APPROVED) requireReversibleFulfillment(tenantId, orderId); + + List changes = new ArrayList<>(); + change(changes, null, "customerRemark", order.getCustomerRemark(), request.customerRemark()); + change(changes, null, "expectedArrivalAt", order.getExpectedArrivalAt(), request.expectedArrivalAt()); + change(changes, null, "channelCode", order.getChannelCode(), channelCode); + change(changes, null, "channelName", order.getChannelName(), channelName); + for (OrderDeclarationDtos.Cargo update : request.cargoes()) { + OrderCargoEntity cargo = byItemNo.get(update.itemNo()); + collect(changes, cargo, update); + } + if (changes.isEmpty()) throw new BizException(ResultCode.CONFLICT, "申报资料没有发生变化"); + + boolean changedBefore = Boolean.TRUE.equals(order.getChannelChanged()); + boolean channelChanged = changedBefore || channelChangedNow; + if (repository.updateDeclarationHeader(tenantId, orderId, request.expectedVersion(), operatorId, + request.customerRemark(), request.expectedArrivalAt(), channelCode, channelName, channelChanged) != 1) { + throw new BizException(ResultCode.CONFLICT, "订单版本已变化,请刷新详情后重试"); + } + for (OrderDeclarationDtos.Cargo update : request.cargoes()) { + OrderCargoEntity cargo = byItemNo.get(update.itemNo()); + apply(cargo, update, operatorId); + if (repository.updateDeclarationCargo(tenantId, orderId, cargo) != 1) { + throw new BizException(ResultCode.CONFLICT, "货物资料已变化,请刷新详情后重试"); + } + } + int version = request.expectedVersion() + 1; + recordHistory(tenantId, orderId, operatorId, operatorName, order.getStatus(), changes.size()); + if (order.getStatus() == OrderStatus.APPROVED) { + writeOutbox(order, operatorId, request, channelCode, channelName, channelChanged, changes, version); + } + return new OrderDeclarationDtos.UpdateResponse(order.getOrderNo(), order.getInboundCode(), + request.expectedVersion(), version, changedBefore, channelChanged, List.copyOf(changes)); + } + + private void requireReversibleFulfillment(long tenantId, long orderId) { + var row = unifiedOrders.platformDetail(tenantId, orderId).summary(); + boolean hasIrreversibleFact = row.measurementCompletedAt() != null + || row.preload() != null || row.loading() != null || row.coLoadGroup() != null + || row.feeReviewStatus() != null && !"UNRECORDED".equals(row.feeReviewStatus()); + if (hasIrreversibleFact || !"WAIT_MEASURE".equals(row.mainStatus())) { + throw new BizException(ResultCode.CONFLICT, + "订单已产生测量、分柜、装柜或计费事实,不能修改客户申报资料"); + } + } + + private void collect(List changes, OrderCargoEntity before, + OrderDeclarationDtos.Cargo after) { + int item = after.itemNo(); + change(changes,item,"cargoName",before.getCargoName(),after.cargoName()); change(changes,item,"cargoNameEn",before.getCargoNameEn(),after.cargoNameEn()); + change(changes,item,"skuNo",before.getSkuNo(),after.skuNo()); change(changes,item,"model",before.getModel(),after.model()); + change(changes,item,"packageCount",before.getPackageCount(),after.packageCount()); change(changes,item,"totalQuantity",before.getTotalQuantity(),after.totalQuantity()); + change(changes,item,"lengthCm",before.getLengthCm(),after.lengthCm()); change(changes,item,"widthCm",before.getWidthCm(),after.widthCm()); + change(changes,item,"heightCm",before.getHeightCm(),after.heightCm()); change(changes,item,"grossWeightKg",before.getGrossWeightKg(),after.grossWeightKg()); + change(changes,item,"volumeCbm",before.getVolumeCbm(),after.volumeCbm()); change(changes,item,"netWeightKg",before.getNetWeightKg(),after.netWeightKg()); + change(changes,item,"hsCode",before.getHsCode(),after.hsCode()); change(changes,item,"customsHsCode",before.getCustomsHsCode(),after.customsHsCode()); + change(changes,item,"unitsPerPackage",before.getUnitsPerPackage(),after.unitsPerPackage()); change(changes,item,"unitDeclaredValue",before.getUnitDeclaredValue(),after.unitDeclaredValue()); + change(changes,item,"declaredValue",before.getDeclaredValue(),after.declaredValue()); change(changes,item,"currency",before.getCurrency(),after.currency()); + change(changes,item,"material",before.getMaterial(),after.material()); change(changes,item,"usage",before.getProductUsage(),after.usage()); + change(changes,item,"brand",before.getBrand(),after.brand()); change(changes,item,"cargoRemark",before.getCargoRemark(),after.cargoRemark()); + } + + private void apply(OrderCargoEntity value, OrderDeclarationDtos.Cargo source, long operatorId) { + value.setCargoName(source.cargoName()); value.setCargoNameEn(source.cargoNameEn()); value.setSkuNo(source.skuNo()); value.setModel(source.model()); + value.setPackageCount(source.packageCount()); value.setTotalQuantity(source.totalQuantity()); value.setLengthCm(source.lengthCm()); value.setWidthCm(source.widthCm()); value.setHeightCm(source.heightCm()); + value.setGrossWeightKg(source.grossWeightKg()); value.setVolumeCbm(source.volumeCbm()); value.setNetWeightKg(source.netWeightKg()); value.setHsCode(source.hsCode()); value.setCustomsHsCode(source.customsHsCode()); + value.setUnitsPerPackage(source.unitsPerPackage()); value.setUnitDeclaredValue(source.unitDeclaredValue()); value.setDeclaredValue(source.declaredValue()); value.setCurrency(source.currency()); + value.setMaterial(source.material()); value.setProductUsage(source.usage()); value.setBrand(source.brand()); value.setCargoRemark(source.cargoRemark()); value.setUpdatedBy(operatorId); value.setUpdatedAt(LocalDateTime.now()); + } + + private void recordHistory(long tenantId, long orderId, long operatorId, String operatorName, + OrderStatus status, int changedFields) { + OrderStatusHistoryEntity history = new OrderStatusHistoryEntity(); + history.setTenantId(tenantId); history.setOrderId(orderId); history.setFromStatus(status); history.setToStatus(status); + history.setAction("DECLARATION_UPDATE"); history.setReason("平台修改客户申报资料,共 " + changedFields + " 个字段"); + history.setOperatorId(operatorId); history.setOperatorName(operatorName); history.setOccurredAt(LocalDateTime.now()); + repository.insertHistory(history); + } + + private void writeOutbox(OrderEntity order, long operatorId, OrderDeclarationDtos.UpdateRequest request, + String channelCode, String channelName, boolean channelChanged, + List changes, int version) { + LocalDateTime occurredAt = LocalDateTime.now(); + var payload = new OrderDeclarationDtos.UpdatedEvent(1, UUID.randomUUID().toString(), order.getTenantId(), + order.getId(), order.getOrderNo(), order.getInboundCode(), version, operatorId, occurredAt, + request.customerRemark(), request.expectedArrivalAt(), channelCode, channelName, channelChanged, + request.cargoes().stream().sorted(Comparator.comparing(OrderDeclarationDtos.Cargo::itemNo)).toList(), + List.copyOf(changes)); + OutboxEventEntity event = new OutboxEventEntity(); + event.setTenantId(order.getTenantId()); event.setAggregateType("FIRST_LEG_ORDER"); event.setAggregateId(order.getId()); + event.setEventType("ORDER_DECLARATION_UPDATED_V1"); + try { event.setPayloadJson(json.writeValueAsString(payload)); } + catch (JsonProcessingException exception) { throw new BizException("Cannot serialize declaration update event"); } + event.setPublishStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(occurredAt); + repository.insertOutbox(event); + } + + private void change(List changes, Integer itemNo, String field, + Object before, Object after) { + if (!Objects.equals(before, after)) changes.add(new OrderDeclarationDtos.FieldChange(itemNo, field, before, after)); + } + private String value(String requested, String current) { return StringUtils.hasText(requested) ? requested.trim() : current; } +} diff --git a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java index 276cf75d1..64417f6a0 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java +++ b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java @@ -5,6 +5,7 @@ import jakarta.annotation.Resource; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.order.client.FirstlegInboundClient; import com.baiyue.order.entity.OutboxEventEntity; +import com.baiyue.order.dto.OrderDeclarationDtos; import com.baiyue.order.repository.OrderRepository; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; @@ -26,8 +27,15 @@ public class OrderInboundOutboxPublisher { @Scheduled(fixedDelayString = "${baiyue.order.inbound-outbox.fixed-delay-ms:3000}") public void publishApprovedOrders() { for (OutboxEventEntity event : repository.pendingInboundEvents(50)) { - if (!"FIRST_LEG_ORDER".equals(event.getAggregateType()) - || !"FIRST_LEG_ORDER_APPROVED".equals(event.getEventType())) { + if (!"FIRST_LEG_ORDER".equals(event.getAggregateType())) { + failPermanently(event, "unsupported order event"); + continue; + } + if ("ORDER_DECLARATION_UPDATED_V1".equals(event.getEventType())) { + publishDeclarationUpdate(event); + continue; + } + if (!"FIRST_LEG_ORDER_APPROVED".equals(event.getEventType())) { failPermanently(event, "unsupported order event"); continue; } @@ -73,6 +81,26 @@ public class OrderInboundOutboxPublisher { } } + private void publishDeclarationUpdate(OutboxEventEntity event) { + try { + OrderDeclarationDtos.UpdatedEvent command = json.readValue( + event.getPayloadJson(), OrderDeclarationDtos.UpdatedEvent.class); + if (command == null || event.getTenantId() == null || event.getAggregateId() == null + || event.getTenantId() != command.tenantId() + || event.getAggregateId() != command.orderId()) { + failPermanently(event, "declaration event ownership does not match payload"); + return; + } + ApiResponse response = client.updateDeclaration(internalToken, command); + if (response == null || !response.successful()) throw new IllegalStateException("头程申报同步失败"); + repository.markOutboxPublished(event.getTenantId(), event.getId(), LocalDateTime.now()); + } catch (com.fasterxml.jackson.core.JsonProcessingException exception) { + failPermanently(event, "invalid declaration event payload"); + } catch (Exception exception) { + repository.markOutboxRetry(event.getTenantId(), event.getId(), LocalDateTime.now().plusSeconds(10)); + } + } + private boolean incomplete(FirstlegInboundClient.OrderApprovedInboundCommand command) { return command.orderNo() == null || command.orderNo().isBlank() || command.inboundCode() == null || command.inboundCode().isBlank() diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index dc12fedd0..24e2dadb3 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -284,13 +284,13 @@ public class UnifiedOrderQueryService { order.getStatus() == OrderStatus.SUBMITTED ? "APPROVE_ORDER" : null, relation(order, childCounts), order.getStatus(), order.getCustomerId(), order.getCustomerName(), - order.getChannelCode(), order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), + order.getChannelCode(), order.getChannelName(), Boolean.TRUE.equals(order.getChannelChanged()), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null, order.getInboundCode(), - order.getExpectedShipDate(), totalPackageCount(cargo), null, null, grossWeightKg(cargo), volumeCbm(cargo), + order.getExpectedShipDate(), totalPackageCount(cargo), null, null, volumeCbm(cargo), null, null, null, null, null, null, order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(), order.getAmazonWarehouseCode(), - null, null, null, null, netWeightKg(cargo), null, null, order.getExpressCompany(), order.getExpressTrackingNo(), + null, null, null, null, null, null, null, order.getExpressCompany(), order.getExpressTrackingNo(), StringUtils.hasText(creatorName) ? creatorName : string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo), null, null, null, null, null, coLoadGroup, order.getCustomerRemark(), latestInternalNote, @@ -300,15 +300,15 @@ public class UnifiedOrderQueryService { return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), summary.mainStatus(), summary.nextAction(), relation(order, childCounts), order.getStatus(), order.getCustomerId(), order.getCustomerName(), - order.getChannelCode(), order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), + order.getChannelCode(), order.getChannelName(), Boolean.TRUE.equals(order.getChannelChanged()), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(), - order.getExpectedShipDate(), inbound.totalPackageCount() != null ? inbound.totalPackageCount() : totalPackageCount(cargo), null, inbound.locationCodes(), - inbound.actualGrossWeightKg() != null ? inbound.actualGrossWeightKg() : grossWeightKg(cargo), - inbound.actualVolumeCbm() != null ? inbound.actualVolumeCbm() : volumeCbm(cargo), inbound.expectedArrivalAt(), inbound.feeReviewStatus(), + order.getExpectedShipDate(), inbound.totalPackageCount() != null ? inbound.totalPackageCount() : totalPackageCount(cargo), null, inbound.locationCodes(), volumeCbm(cargo), + inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), + order.getExpectedArrivalAt() != null ? order.getExpectedArrivalAt() : inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(), inbound.businessType(), order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(), inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), - inbound.actualNetWeightKg() != null ? inbound.actualNetWeightKg() : netWeightKg(cargo), + inbound.actualNetWeightKg(), inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(), StringUtils.hasText(creatorName) ? creatorName : inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo), measurement(inbound), preload(summary.preload()), loading(summary.loading()), @@ -324,18 +324,17 @@ public class UnifiedOrderQueryService { return new UnifiedOrderResponse.Row(inbound.orderId(), inbound.tenantId(), inbound.orderNo(), summary.mainStatus(), summary.nextAction(), relation(order, childCounts), parseOrderStatus(inbound.orderStatusSnapshot()), - inbound.customerId(), inbound.customerName(), inbound.channelCode(), inbound.channelName(), inbound.warehouseId(), + inbound.customerId(), inbound.customerName(), inbound.channelCode(), inbound.channelName(), + order != null && Boolean.TRUE.equals(order.getChannelChanged()), inbound.warehouseId(), inbound.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(), null, - inbound.totalPackageCount() != null ? inbound.totalPackageCount() : totalPackageCount(cargo), - null, inbound.locationCodes(), - inbound.actualGrossWeightKg() != null ? inbound.actualGrossWeightKg() : grossWeightKg(cargo), - inbound.actualVolumeCbm() != null ? inbound.actualVolumeCbm() : volumeCbm(cargo), - inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(), + inbound.totalPackageCount() != null ? inbound.totalPackageCount() : totalPackageCount(cargo), null, inbound.locationCodes(), volumeCbm(cargo), + inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), + order != null && order.getExpectedArrivalAt() != null ? order.getExpectedArrivalAt() : inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(), inbound.businessType(), order == null ? null : order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(), inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), - inbound.actualNetWeightKg() != null ? inbound.actualNetWeightKg() : netWeightKg(cargo), + inbound.actualNetWeightKg(), inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(), inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo), measurement(inbound), preload(summary.preload()), loading(summary.loading()), diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index 281a1eaed..91651e923 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓逻辑ID', destination_warehouse_name VARCHAR(128) NOT NULL COMMENT '目的仓名称快照', expected_ship_date DATE NULL COMMENT '预计发运日期', + expected_arrival_at DATETIME(3) NULL COMMENT '客户申报预计到货时间', customer_remark VARCHAR(1000) NULL COMMENT '客户可见备注', pickup_method VARCHAR(24) NULL COMMENT '取货方式', requested_pickup_at DATETIME(3) NULL COMMENT '客户要求平台上门提货的预约时间', @@ -28,6 +29,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( receiving_time VARCHAR(128) NULL COMMENT '国内收货仓收货时间快照', channel_code VARCHAR(64) NULL COMMENT '渠道编码', channel_name VARCHAR(128) NULL COMMENT '渠道名称快照', + channel_changed TINYINT(1) NOT NULL DEFAULT 0 COMMENT '订单历史上是否由平台变更过渠道', carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID快照', carrier_name VARCHAR(128) NULL COMMENT '船司名称快照', tax_mode VARCHAR(24) NULL COMMENT '买单或退税', diff --git a/order/src/main/resources/db/platform-order-declaration-edit.sql b/order/src/main/resources/db/platform-order-declaration-edit.sql new file mode 100644 index 000000000..6674fca18 --- /dev/null +++ b/order/src/main/resources/db/platform-order-declaration-edit.sql @@ -0,0 +1,22 @@ +-- 尚未执行。测试库执行前需获得当前轮 SQL 授权。 +USE baiyue_order; + +ALTER TABLE ord_order + ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT '客户申报预计到货时间' AFTER expected_ship_date, + ADD COLUMN channel_changed TINYINT(1) NOT NULL DEFAULT 0 COMMENT '订单历史上是否由平台变更过渠道' AFTER channel_name; + +USE baiyue_iam; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, + route_path, http_method, sort_no, status, deleted) +VALUES + (5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit', + '修改客户申报资料', NULL, NULL, 121, 'ACTIVE', 0) +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), + sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT role_id, 5102027, 0 +FROM (SELECT 4000001 AS role_id UNION ALL SELECT 4000003 AS role_id) roles +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 543912791..c51731040 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -206,6 +206,25 @@ paths: responses: '200': {description: 备注添加成功或业务失败,按响应体 code 判断} '403': {description: 需要 platform:order:note 权限} + /api/platform/v1/orders/{orderId}/declaration: + put: + summary: 版本化修改客户原始申报资料 + description: >- + 需要 platform:order:declaration:edit。货物使用订单内 itemNo 定位,不接受货物数据库 ID; + 不允许修改客户、国内收货仓、订单号、入仓码、状态、仓库/PDA/设备实测、测量审核或计费事实。 + 审核通过订单仅在尚无测量、同柜组、分柜、装柜或计费事实时受理,并通过 + ORDER_DECLARATION_UPDATED_V1 Outbox 快照同步 Firstleg;消费者按 eventId 幂等、version 顺序应用。 + parameters: + - $ref: '#/components/parameters/UnifiedOrderId' + - $ref: '#/components/parameters/UnifiedTenantId' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/OrderDeclarationUpdateRequest'} + responses: + '200': {description: 成功返回新版本、渠道历史标记和字段级前后值;版本冲突或存在不可逆履约事实时响应体业务码为 A0409} + '403': {description: 无权限或非平台身份} /api/merchant/v1/orders: get: summary: 客户统一订单列表 @@ -553,6 +572,43 @@ components: fbaCode: {type: string, maxLength: 64} createdFrom: {type: string, format: date-time} createdTo: {type: string, format: date-time} + OrderDeclarationUpdateRequest: + type: object + required: [expectedVersion, cargoes] + properties: + expectedVersion: {type: integer, minimum: 0} + customerRemark: {type: string, nullable: true, maxLength: 1000} + expectedArrivalAt: {type: string, format: date-time, nullable: true} + channelCode: {type: string, maxLength: 64, description: 与 channelName 同时传;变更时重新校验价格渠道,入仓码保持不变} + channelName: {type: string, maxLength: 128} + cargoes: {type: array, minItems: 1, maxItems: 500, items: {$ref: '#/components/schemas/OrderDeclarationCargo'}} + OrderDeclarationCargo: + type: object + required: [itemNo, cargoName, packageCount, totalQuantity, lengthCm, widthCm, heightCm, grossWeightKg, volumeCbm] + properties: + itemNo: {type: integer, minimum: 1, maximum: 500, description: 订单内稳定货物行号} + cargoName: {type: string, maxLength: 128} + cargoNameEn: {type: string, nullable: true, maxLength: 128} + skuNo: {type: string, nullable: true, maxLength: 64} + model: {type: string, nullable: true, maxLength: 64} + packageCount: {type: integer, minimum: 1} + totalQuantity: {type: integer, minimum: 1} + lengthCm: {type: number, minimum: 0.01} + widthCm: {type: number, minimum: 0.01} + heightCm: {type: number, minimum: 0.01} + grossWeightKg: {type: number, minimum: 0.001} + volumeCbm: {type: number, minimum: 0.0001} + netWeightKg: {type: number, nullable: true, minimum: 0.001} + hsCode: {type: string, nullable: true, maxLength: 20} + customsHsCode: {type: string, nullable: true, maxLength: 20} + unitsPerPackage: {type: integer, nullable: true, minimum: 1} + unitDeclaredValue: {type: number, nullable: true, minimum: 0} + declaredValue: {type: number, nullable: true, minimum: 0} + currency: {type: string, nullable: true, pattern: '^[A-Z]{3}$'} + material: {type: string, nullable: true, maxLength: 128} + usage: {type: string, nullable: true, maxLength: 128} + brand: {type: string, nullable: true, maxLength: 64} + cargoRemark: {type: string, nullable: true, maxLength: 500} TenantOrderStatisticsResponse: type: object required: [tenantId, totalSubmittedOrders, currentMonthSubmittedOrders, monthStart, nextMonthStart, zoneId, asOf] @@ -616,6 +672,7 @@ components: customerName: {type: string, nullable: true} channelCode: {type: string, nullable: true, description: 稳定物流渠道编码,仅供业务校验;直接来自当前 Order 或 Firstleg 权威快照,不由 channelName 推导} channelName: {type: string, nullable: true} + channelChanged: {type: boolean, description: 当前订单历史上是否由平台变更过渠道} receivingWarehouseId: {type: integer, format: int64, nullable: true} receivingWarehouseName: {type: string, nullable: true} destinationWarehouseId: {type: integer, format: int64, nullable: true} @@ -627,8 +684,9 @@ components: totalPackageCount: {type: integer, nullable: true, description: 无入仓事实时为当前订单有效货物箱数合计;存在入仓业务单元后为该单元履约总箱数,拆分后不以父单原始箱数覆盖} recommendedPalletType: {type: string, nullable: true, description: 当前无列表级权威事实时返回 null,不得返回固定托盘类型} locationCodes: {type: string, nullable: true} - actualGrossWeightKg: {type: number, nullable: true, description: Firstleg 实测毛重;来源 actualGrossWeightKg,不以净重回退} - actualVolumeCbm: {type: number, nullable: true} + expectedVolumeCbm: {type: number, nullable: true, description: Order 客户申报货物体积合计,不得与实测体积混用} + actualGrossWeightKg: {type: number, nullable: true, description: 仅 Firstleg 实测毛重;无实测时返回 null} + actualVolumeCbm: {type: number, nullable: true, description: 仅 Firstleg 实测体积;无实测时返回 null} expectedArrivalAt: {type: string, format: date-time, nullable: true} feeReviewStatus: {type: string, nullable: true, description: Firstleg 入仓费用审核快照;财务状态不在本字段表达} companyName: {type: string, nullable: true} diff --git a/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java b/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java new file mode 100644 index 000000000..49094568d --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java @@ -0,0 +1,52 @@ +package com.baiyue.order.controller; + +import com.baiyue.order.dto.OrderDeclarationDtos; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PutMapping; +import org.yaml.snakeyaml.Yaml; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class OrderDeclarationUpdateContractTest { + @Test + void endpointUsesDedicatedPermissionAndVersionedDto() throws Exception { + var method = PlatformUnifiedOrderController.class.getDeclaredMethod("updateDeclaration", + com.baiyue.common.security.AuthenticatedUser.class, long.class, long.class, + OrderDeclarationDtos.UpdateRequest.class); + assertThat(method.getAnnotation(PutMapping.class).value()).containsExactly("/{orderId}/declaration"); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:order:declaration:edit')"); + assertThat(OrderDeclarationDtos.UpdateRequest.class.getRecordComponents()) + .extracting(component -> component.getName()) + .containsExactly("expectedVersion", "customerRemark", "expectedArrivalAt", + "channelCode", "channelName", "cargoes"); + assertThat(OrderDeclarationDtos.Cargo.class.getRecordComponents()) + .extracting(component -> component.getName()) + .contains("itemNo", "cargoName", "cargoNameEn", "skuNo", "model", "packageCount", + "totalQuantity", "lengthCm", "widthCm", "heightCm", "grossWeightKg", "volumeCbm") + .doesNotContain("id", "orderId", "actualGrossWeightKg", "actualVolumeCbm", + "measurementReviewStatus", "feeReviewStatus"); + } + + @Test + void openApiSeparatesDeclaredAndMeasuredFacts() throws Exception { + String source = new ClassPathResource("openapi/order-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + Map document = map(new Yaml().load(source)); + assertThat(map(document.get("paths"))) + .containsKey("/api/platform/v1/orders/{orderId}/declaration"); + Map schemas = map(map(document.get("components")).get("schemas")); + Map row = map(map(schemas.get("UnifiedOrderRow")).get("properties")); + assertThat(row).containsKeys("expectedVolumeCbm", "actualVolumeCbm", "expectedArrivalAt", "channelChanged"); + assertThat(map(row.get("actualVolumeCbm")).get("description").toString()).contains("仅 Firstleg 实测"); + assertThat(source).contains("ORDER_DECLARATION_UPDATED_V1", "platform:order:declaration:edit", "A0409"); + } + + @SuppressWarnings("unchecked") + private Map map(Object value) { return (Map) value; } +} diff --git a/order/src/test/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderServiceTest.java b/order/src/test/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderServiceTest.java index 8085ca724..2a8c04772 100644 --- a/order/src/test/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderServiceTest.java @@ -52,6 +52,7 @@ class DomesticWarehouseOrderExportProviderServiceTest { .doesNotContainKeys("orderId", "tenantId", "customerId", "inboundId", "receivingWarehouseId", "destinationWarehouseId"); assertThat(response.columns()).extracting(column -> column.key()) + .contains("expectedVolumeCbm", "expectedArrivalAt", "channelChanged") .doesNotContain("orderId", "tenantId", "customerId", "inboundId"); verify(orders, never()).platformPage(any(), any()); } diff --git a/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java new file mode 100644 index 000000000..bfb7fc338 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java @@ -0,0 +1,148 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.dto.OrderDeclarationDtos; +import com.baiyue.order.dto.UnifiedOrderResponse; +import com.baiyue.order.entity.OrderCargoEntity; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderStatus; +import com.baiyue.order.repository.OrderRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(OrderDeclarationUpdateService.class) +class OrderDeclarationUpdateServiceTest { + @MockBean private OrderRepository repository; + @MockBean private OrderPricingChannelGuard pricingChannelGuard; + @MockBean private UnifiedOrderQueryService unifiedOrders; + @MockBean private ObjectMapper json; + @Resource private OrderDeclarationUpdateService service; + + @Test + void updatesCompleteCustomerDeclarationByItemNoAndKeepsInboundCode() { + OrderEntity order = order(OrderStatus.SUBMITTED, 3); + OrderCargoEntity cargo = cargo(); + when(repository.findOwned(100L, 200L)).thenReturn(order); + when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo)); + when(repository.updateDeclarationHeader(eq(100L), eq(200L), eq(3), eq(700L), + any(), any(), anyString(), anyString(), anyBoolean())).thenReturn(1); + when(repository.updateDeclarationCargo(eq(100L), eq(200L), any())).thenReturn(1); + + var request = request(3, "NEW", "新渠道", "新备注"); + var response = service.update(100L, 200L, 700L, "平台操作员", request); + + assertThat(response.orderNo()).isEqualTo("ORD-200"); + assertThat(response.inboundCode()).isEqualTo("RK-LOCKED"); + assertThat(response.version()).isEqualTo(4); + assertThat(response.channelChanged()).isTrue(); + assertThat(response.changes()).extracting(OrderDeclarationDtos.FieldChange::field) + .contains("channelCode", "channelName", "customerRemark", "expectedArrivalAt", + "cargoName", "packageCount", "totalQuantity", "lengthCm", "grossWeightKg"); + verify(pricingChannelGuard).requireAvailable(100L, 31L, "NEW", "FBA"); + verify(repository).insertHistory(any()); + verify(repository, never()).insertOutbox(any()); + assertThat(cargo.getPackageCount()).isEqualTo(20); + assertThat(cargo.getTotalQuantity()).isEqualTo(200); + assertThat(cargo.getLengthCm()).isEqualByComparingTo("60"); + } + + @Test + void rejectsStaleVersionBeforeAnyWrite() { + when(repository.findOwned(100L, 200L)).thenReturn(order(OrderStatus.SUBMITTED, 4)); + assertThatThrownBy(() -> service.update(100L, 200L, 700L, "平台操作员", + request(3, "OLD", "旧渠道", "备注"))) + .isInstanceOf(BizException.class).hasMessageContaining("版本已变化"); + verify(repository, never()).updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(), + any(), any(), any(), any(), anyBoolean()); + } + + @Test + void approvedOrderWithMeasurementFactIsAtomicallyRejected() { + OrderEntity order = order(OrderStatus.APPROVED, 3); + when(repository.findOwned(100L, 200L)).thenReturn(order); + when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo())); + UnifiedOrderResponse.Detail detail = org.mockito.Mockito.mock(UnifiedOrderResponse.Detail.class); + UnifiedOrderResponse.Row row = org.mockito.Mockito.mock(UnifiedOrderResponse.Row.class); + when(detail.summary()).thenReturn(row); + when(row.mainStatus()).thenReturn("WAIT_MEASURE"); + when(row.measurementCompletedAt()).thenReturn(LocalDateTime.of(2026, 9, 1, 12, 0)); + when(unifiedOrders.platformDetail(100L, 200L)).thenReturn(detail); + + assertThatThrownBy(() -> service.update(100L, 200L, 700L, "平台操作员", + request(3, "OLD", "旧渠道", "备注"))) + .isInstanceOf(BizException.class).hasMessageContaining("测量、分柜、装柜或计费事实"); + verify(repository, never()).updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(), + any(), any(), any(), any(), anyBoolean()); + } + + @Test + void reversibleApprovedUpdateProducesVersionedOutboxSnapshot() throws Exception { + OrderEntity order = order(OrderStatus.APPROVED, 3); + when(repository.findOwned(100L, 200L)).thenReturn(order); + when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo())); + when(repository.updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(), any(), any(), + any(), any(), anyBoolean())).thenReturn(1); + when(repository.updateDeclarationCargo(anyLong(), anyLong(), any())).thenReturn(1); + UnifiedOrderResponse.Detail detail = org.mockito.Mockito.mock(UnifiedOrderResponse.Detail.class); + UnifiedOrderResponse.Row row = org.mockito.Mockito.mock(UnifiedOrderResponse.Row.class); + when(detail.summary()).thenReturn(row); + when(row.mainStatus()).thenReturn("WAIT_MEASURE"); + when(row.feeReviewStatus()).thenReturn("UNRECORDED"); + when(unifiedOrders.platformDetail(100L, 200L)).thenReturn(detail); + when(json.writeValueAsString(any())).thenReturn("{\"schemaVersion\":1}"); + + service.update(100L, 200L, 700L, "平台操作员", request(3, "OLD", "旧渠道", "新备注")); + + verify(repository).insertOutbox(any()); + } + + private OrderEntity order(OrderStatus status, int version) { + OrderEntity order = new OrderEntity(); + order.setId(200L); order.setTenantId(100L); order.setOrderNo("ORD-200"); + order.setInboundCode("RK-LOCKED"); order.setStatus(status); order.setVersion(version); + order.setReceivingWarehouseId(31L); order.setDestinationType("FBA"); + order.setChannelCode("OLD"); order.setChannelName("旧渠道"); order.setChannelChanged(false); + order.setCustomerRemark("旧备注"); + return order; + } + + private OrderCargoEntity cargo() { + OrderCargoEntity value = new OrderCargoEntity(); + value.setId(300L); value.setTenantId(100L); value.setOrderId(200L); value.setItemNo(1); value.setVersion(2); + value.setCargoName("旧品名"); value.setCargoNameEn("Old"); value.setSkuNo("SKU-OLD"); value.setModel("M1"); + value.setPackageCount(10); value.setTotalQuantity(100); value.setLengthCm(new BigDecimal("50")); + value.setWidthCm(new BigDecimal("40")); value.setHeightCm(new BigDecimal("30")); + value.setGrossWeightKg(new BigDecimal("100")); value.setVolumeCbm(new BigDecimal("1.0")); + return value; + } + + private OrderDeclarationDtos.UpdateRequest request(int version, String channelCode, + String channelName, String remark) { + var cargo = new OrderDeclarationDtos.Cargo(1, "新品名", "New", "SKU-NEW", "M2", + 20, 200, new BigDecimal("60"), new BigDecimal("50"), new BigDecimal("40"), + new BigDecimal("120"), new BigDecimal("1.2"), new BigDecimal("110"), + "8518", "851821", 10, new BigDecimal("5"), new BigDecimal("1000"), + "USD", "塑料", "音响", "Brand", "新备注"); + return new OrderDeclarationDtos.UpdateRequest(version, remark, + LocalDateTime.of(2026, 9, 15, 10, 30), channelCode, channelName, List.of(cargo)); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java index d49e02bd0..ca93490e5 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java @@ -129,6 +129,22 @@ class OrderInboundOutboxPublisherTest { verify(fixture.repository, never()).markOutboxFailed(any(), anyLong()); } + @Test + void declarationUpdateEventIsDeliveredByVersionedContract() { + String payload = "{\"schemaVersion\":1,\"eventId\":\"evt-1\",\"tenantId\":11," + + "\"orderId\":21,\"orderNo\":\"ORD-21\",\"inboundCode\":\"RK-21\"," + + "\"version\":4,\"operatorId\":7,\"channelCode\":\"SEA\"," + + "\"channelName\":\"海运\",\"channelChanged\":true,\"cargoes\":[],\"changes\":[]}"; + Fixture fixture = fixture(event("ORDER_DECLARATION_UPDATED_V1", 11L, 21L, payload)); + when(fixture.client.updateDeclaration(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); + + fixture.publisher.publishApprovedOrders(); + + verify(fixture.client).updateDeclaration(eq("internal-token"), any()); + verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class)); + verify(fixture.client, never()).create(any(), any()); + } + private Fixture fixture(OutboxEventEntity event) { return fixture(event, "BUY_DECLARATION"); } diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index 18baf4d9b..1df73770b 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -98,9 +98,10 @@ class UnifiedOrderQueryServiceTest { assertThat(service.page(10001L, query).records()).singleElement().satisfies(row -> { assertThat(row.totalPackageCount()).isEqualTo(100); - assertThat(row.actualGrossWeightKg()).isEqualByComparingTo("12.5"); - assertThat(row.totalNetWeightKg()).isEqualByComparingTo("11.2"); - assertThat(row.actualVolumeCbm()).isEqualByComparingTo("0.375"); + assertThat(row.expectedVolumeCbm()).isEqualByComparingTo("0.375"); + assertThat(row.actualGrossWeightKg()).isNull(); + assertThat(row.totalNetWeightKg()).isNull(); + assertThat(row.actualVolumeCbm()).isNull(); }); verify(orders).findCargoByOrderIds(10001L, List.of(77L)); }