From fd3dbb2fc635d62b8a73ee1d09a7951f02b64588 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 3 Sep 2026 11:47:28 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 12 ++++ .../PlatformUnifiedOrderController.java | 16 +++++ .../order/dto/OrderDeclarationDtos.java | 8 +++ .../order/dto/UnifiedOrderResponse.java | 5 +- .../com/baiyue/order/entity/OrderEntity.java | 2 + .../order/repository/OrderRepository.java | 2 + .../OrderDeclarationUpdateService.java | 61 ++++++++++++++++++- .../service/UnifiedOrderQueryService.java | 17 +++--- order/src/main/resources/db/order.sql | 1 + .../src/main/resources/openapi/order-v1.yaml | 27 +++++++- .../OrderDeclarationUpdateContractTest.java | 22 ++++++- .../OrderDeclarationUpdateServiceTest.java | 42 ++++++++++--- 12 files changed, 194 insertions(+), 21 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 9ea4bb4aa..0f11f7930 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -565,6 +565,8 @@ 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 '客户申报预计到货时间', + estimated_volume_cbm DECIMAL(18,4) NULL COMMENT '平台运营预估体积(立方米),独立于客户下单及实测体积', customer_remark VARCHAR(1000) NULL COMMENT '客户可见备注', pickup_method VARCHAR(24) NULL COMMENT '取货方式', requested_pickup_at DATETIME(3) NULL COMMENT '客户要求平台上门提货的预约时间', @@ -574,6 +576,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 '报关税务方式:BUY_DECLARATION买单、TAX_REFUND退税', @@ -16729,6 +16732,15 @@ CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','updat CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_event','consumed_at','事件消费完成时间'); CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','created_at','快照创建时间'); CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','updated_at','快照更新时间'); +SET @unified_ddl = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA='baiyue_order' AND TABLE_NAME='ord_order' AND COLUMN_NAME='estimated_volume_cbm') = 0, + 'ALTER TABLE baiyue_order.ord_order ADD COLUMN estimated_volume_cbm DECIMAL(18,4) NULL COMMENT ''平台运营预估体积(立方米),独立于客户下单及实测体积'' AFTER expected_arrival_at', + 'SELECT 1' +); +PREPARE unified_stmt FROM @unified_ddl; EXECUTE unified_stmt; DEALLOCATE PREPARE unified_stmt; + +CALL queue_column_comment('baiyue_order','ord_order','estimated_volume_cbm','平台运营预估体积(立方米),独立于客户下单及实测体积'); CALL queue_column_comment('baiyue_order','ord_customer_address','created_by','创建人ID'); CALL queue_column_comment('baiyue_order','ord_customer_address','created_at','创建时间'); CALL queue_column_comment('baiyue_order','ord_customer_address','updated_by','最后修改人ID'); 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 0912d7618..33aab072f 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java @@ -78,6 +78,22 @@ public class PlatformUnifiedOrderController { user.username(), request)); } + @PutMapping("/{orderId}/planning") + @PreAuthorize("hasAuthority('platform:order:declaration:edit')") + @BusinessAudit(event = "ORDER_PLANNING_UPDATE", resourceType = "FIRST_LEG_ORDER", + resourceId = "#orderId", tenantId = "#tenantId", description = "平台修改预计到货时间和预估体积") + @Operation(summary = "版本化修改订单运营预估", + description = "只修改预计到货时间和平台预估体积;客户下单体积与仓库实测体积保持独立。") + public ApiResponse updatePlanning( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, + @PathVariable long orderId, + @Valid @RequestBody OrderDeclarationDtos.PlanningUpdateRequest request) { + long scopedTenant = OrderScopeGuard.platform(user, tenantId); + return ApiResponse.success(declarationUpdates.updatePlanning(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 index d05bfdfee..21683c060 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderDeclarationDtos.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderDeclarationDtos.java @@ -22,11 +22,19 @@ public final class OrderDeclarationDtos { @NotNull @Min(0) Integer expectedVersion, @Size(max = 1000) String customerRemark, LocalDateTime expectedArrivalAt, + @DecimalMin("0.0001") BigDecimal estimatedVolumeCbm, @Size(max = 64) String channelCode, @Size(max = 128) String channelName, @NotEmpty @Size(max = 500) List<@Valid Cargo> cargoes ) {} + /** 订单列表只修改运营预估;服务端复用当前完整申报快照,避免浏览器拼装货物明细。 */ + public record PlanningUpdateRequest( + @NotNull @Min(0) Integer expectedVersion, + LocalDateTime expectedArrivalAt, + @DecimalMin("0.0001") BigDecimal estimatedVolumeCbm + ) {} + /** itemNo 是订单内稳定业务行号;不以货物数据库 ID 作为编辑契约。 */ public record Cargo( @NotNull @Min(1) @Max(500) Integer itemNo, 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 70ce132e3..ef47551f5 100644 --- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java @@ -12,7 +12,7 @@ public final class UnifiedOrderResponse { private UnifiedOrderResponse() {} public record Row( - Long orderId, Long tenantId, String orderNo, String mainStatus, String nextAction, + Long orderId, Integer orderVersion, Long tenantId, String orderNo, String mainStatus, String nextAction, OrderRelationSummary relation, OrderStatus internalOrderStatus, Long customerId, String customerName, String channelCode, String channelName, Boolean channelChanged, @@ -20,7 +20,8 @@ public final class UnifiedOrderResponse { Long destinationWarehouseId, String destinationWarehouseName, Long inboundId, Integer inboundVersion, String inboundCode, LocalDate expectedShipDate, Integer totalPackageCount, String recommendedPalletType, String locationCodes, - BigDecimal expectedVolumeCbm, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm, + BigDecimal estimatedVolumeCbm, 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 79782f8d4..8f59e7bbc 100644 --- a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java +++ b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java @@ -1,5 +1,6 @@ package com.baiyue.order.entity; +import java.math.BigDecimal; import lombok.Getter; import lombok.Setter; import com.baiyue.common.core.model.BaseEntity; @@ -32,6 +33,7 @@ public class OrderEntity extends BaseEntity { private String destinationWarehouseName; private LocalDate expectedShipDate; private LocalDateTime expectedArrivalAt; + private BigDecimal estimatedVolumeCbm; private String customerRemark; private String pickupMethod; private LocalDateTime requestedPickupAt; 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 0bf9cbe27..c2aa906b2 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -56,6 +56,7 @@ public class OrderRepository { } public int updateDeclarationHeader(long tenantId, long orderId, int expectedVersion, long operatorId, String customerRemark, LocalDateTime expectedArrivalAt, + java.math.BigDecimal estimatedVolumeCbm, String channelCode, String channelName, boolean channelChanged) { return orderMapper.update(null, new LambdaUpdateWrapper() .eq(OrderEntity::getTenantId, tenantId) @@ -64,6 +65,7 @@ public class OrderRepository { .eq(OrderEntity::getDeleted, false) .set(OrderEntity::getCustomerRemark, customerRemark) .set(OrderEntity::getExpectedArrivalAt, expectedArrivalAt) + .set(OrderEntity::getEstimatedVolumeCbm, estimatedVolumeCbm) .set(OrderEntity::getChannelCode, channelCode) .set(OrderEntity::getChannelName, channelName) .set(OrderEntity::getChannelChanged, channelChanged) diff --git a/order/src/main/java/com/baiyue/order/service/OrderDeclarationUpdateService.java b/order/src/main/java/com/baiyue/order/service/OrderDeclarationUpdateService.java index ef2cb4e37..619f1bfee 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderDeclarationUpdateService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderDeclarationUpdateService.java @@ -39,6 +39,45 @@ public class OrderDeclarationUpdateService { @Resource private OrderFulfillmentChannelChangeGuard fulfillmentChannelGuard; @Resource private ObjectMapper json; + @Transactional + public OrderDeclarationDtos.UpdateResponse updatePlanning(long tenantId, long orderId, long operatorId, + String operatorName, + OrderDeclarationDtos.PlanningUpdateRequest planning) { + 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(), planning.expectedVersion())) { + throw new BizException(ResultCode.CONFLICT, "订单版本已变化,请刷新列表后重试"); + } + List changes = new ArrayList<>(); + change(changes, null, "expectedArrivalAt", order.getExpectedArrivalAt(), planning.expectedArrivalAt()); + change(changes, null, "estimatedVolumeCbm", order.getEstimatedVolumeCbm(), planning.estimatedVolumeCbm()); + if (changes.isEmpty()) throw new BizException(ResultCode.CONFLICT, "运营预估没有发生变化"); + + boolean channelChanged = Boolean.TRUE.equals(order.getChannelChanged()); + if (repository.updateDeclarationHeader(tenantId, orderId, planning.expectedVersion(), operatorId, + order.getCustomerRemark(), planning.expectedArrivalAt(), planning.estimatedVolumeCbm(), + order.getChannelCode(), order.getChannelName(), channelChanged) != 1) { + throw new BizException(ResultCode.CONFLICT, "订单版本已变化,请刷新列表后重试"); + } + int version = planning.expectedVersion() + 1; + recordPlanningHistory(tenantId, orderId, operatorId, operatorName, order.getStatus(), changes.size()); + if (order.getStatus() == OrderStatus.APPROVED) { + List cargoes = repository.findCargo(tenantId, orderId).stream() + .sorted(Comparator.comparing(OrderCargoEntity::getItemNo)) + .map(this::cargoSnapshot) + .toList(); + writeOutbox(order, operatorId, new OrderDeclarationDtos.UpdateRequest( + planning.expectedVersion(), order.getCustomerRemark(), planning.expectedArrivalAt(), + planning.estimatedVolumeCbm(), order.getChannelCode(), order.getChannelName(), cargoes), + order.getChannelCode(), order.getChannelName(), channelChanged, changes, version); + } + return new OrderDeclarationDtos.UpdateResponse(order.getOrderNo(), order.getInboundCode(), + planning.expectedVersion(), version, channelChanged, channelChanged, List.copyOf(changes)); + } + @Transactional public OrderDeclarationDtos.UpdateResponse update(long tenantId, long orderId, long operatorId, String operatorName, @@ -83,6 +122,7 @@ public class OrderDeclarationUpdateService { List changes = new ArrayList<>(); change(changes, null, "customerRemark", order.getCustomerRemark(), request.customerRemark()); change(changes, null, "expectedArrivalAt", order.getExpectedArrivalAt(), request.expectedArrivalAt()); + change(changes, null, "estimatedVolumeCbm", order.getEstimatedVolumeCbm(), request.estimatedVolumeCbm()); change(changes, null, "channelCode", order.getChannelCode(), channelCode); change(changes, null, "channelName", order.getChannelName(), channelName); for (OrderDeclarationDtos.Cargo update : request.cargoes()) { @@ -94,7 +134,8 @@ public class OrderDeclarationUpdateService { 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) { + request.customerRemark(), request.expectedArrivalAt(), request.estimatedVolumeCbm(), + channelCode, channelName, channelChanged) != 1) { throw new BizException(ResultCode.CONFLICT, "订单版本已变化,请刷新详情后重试"); } for (OrderDeclarationDtos.Cargo update : request.cargoes()) { @@ -129,6 +170,15 @@ public class OrderDeclarationUpdateService { change(changes,item,"brand",before.getBrand(),after.brand()); change(changes,item,"cargoRemark",before.getCargoRemark(),after.cargoRemark()); } + private OrderDeclarationDtos.Cargo cargoSnapshot(OrderCargoEntity value) { + return new OrderDeclarationDtos.Cargo(value.getItemNo(), value.getCargoName(), value.getCargoNameEn(), + value.getSkuNo(), value.getModel(), value.getPackageCount(), value.getTotalQuantity(), + value.getLengthCm(), value.getWidthCm(), value.getHeightCm(), value.getGrossWeightKg(), + value.getVolumeCbm(), value.getNetWeightKg(), value.getHsCode(), value.getCustomsHsCode(), + value.getUnitsPerPackage(), value.getUnitDeclaredValue(), value.getDeclaredValue(), + value.getCurrency(), value.getMaterial(), value.getProductUsage(), value.getBrand(), value.getCargoRemark()); + } + 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()); @@ -146,6 +196,15 @@ public class OrderDeclarationUpdateService { repository.insertHistory(history); } + private void recordPlanningHistory(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("PLANNING_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) { 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 25a95c586..8796736a0 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -280,15 +280,16 @@ public class UnifiedOrderQueryService { UnifiedOrderResponse.CoLoadGroupSummary coLoadGroup) { if (summary == null) { String mainStatus = orderMainStatus(order.getStatus()); - return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), mainStatus, + return new UnifiedOrderResponse.Row(order.getId(), order.getVersion(), order.getTenantId(), order.getOrderNo(), mainStatus, order.getStatus() == OrderStatus.SUBMITTED ? "APPROVE_ORDER" : null, relation(order, childCounts), order.getStatus(), order.getCustomerId(), order.getCustomerName(), 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, volumeCbm(cargo), null, null, - null, null, null, null, order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(), + order.getExpectedShipDate(), totalPackageCount(cargo), null, null, + order.getEstimatedVolumeCbm(), volumeCbm(cargo), null, null, + order.getExpectedArrivalAt(), null, null, null, order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(), order.getAmazonWarehouseCode(), 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), @@ -300,12 +301,13 @@ public class UnifiedOrderQueryService { boolean pendingReview = order.getStatus() == OrderStatus.SUBMITTED; String mainStatus = pendingReview ? "PENDING_REVIEW" : summary.mainStatus(); String nextAction = pendingReview ? "APPROVE_ORDER" : summary.nextAction(); - return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), + return new UnifiedOrderResponse.Row(order.getId(), order.getVersion(), order.getTenantId(), order.getOrderNo(), mainStatus, nextAction, relation(order, childCounts), order.getStatus(), order.getCustomerId(), order.getCustomerName(), 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(), volumeCbm(cargo), + order.getExpectedShipDate(), inbound.totalPackageCount() != null ? inbound.totalPackageCount() : totalPackageCount(cargo), null, inbound.locationCodes(), + order.getEstimatedVolumeCbm(), 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(), @@ -324,14 +326,15 @@ public class UnifiedOrderQueryService { CargoSummary cargo, UnifiedOrderResponse.CoLoadGroupSummary coLoadGroup) { var inbound = summary.inbound(); - return new UnifiedOrderResponse.Row(inbound.orderId(), inbound.tenantId(), inbound.orderNo(), + return new UnifiedOrderResponse.Row(inbound.orderId(), order == null ? null : order.getVersion(), inbound.tenantId(), inbound.orderNo(), summary.mainStatus(), summary.nextAction(), relation(order, childCounts), parseOrderStatus(inbound.orderStatusSnapshot()), 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(), volumeCbm(cargo), + inbound.totalPackageCount() != null ? inbound.totalPackageCount() : totalPackageCount(cargo), null, inbound.locationCodes(), + order == null ? null : order.getEstimatedVolumeCbm(), 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(), diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index aa9d75cf2..267003442 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( destination_warehouse_name VARCHAR(128) NOT NULL COMMENT '目的仓名称快照', expected_ship_date DATE NULL COMMENT '预计发运日期', expected_arrival_at DATETIME(3) NULL COMMENT '客户申报预计到货时间', + estimated_volume_cbm DECIMAL(18,4) NULL COMMENT '平台运营预估体积(立方米),独立于客户下单及实测体积', customer_remark VARCHAR(1000) NULL COMMENT '客户可见备注', pickup_method VARCHAR(24) NULL COMMENT '取货方式', requested_pickup_at DATETIME(3) NULL COMMENT '客户要求平台上门提货的预约时间', diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index a31375333..3e9ea4fb9 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -231,6 +231,21 @@ paths: responses: '200': {description: 成功返回新版本、渠道历史标记和字段级前后值;版本冲突、渠道与不可逆履约或已确认费用不兼容时响应体业务码为 A0409} '403': {description: 无权限或非平台身份} + /api/platform/v1/orders/{orderId}/planning: + put: + summary: 版本化修改订单运营预估 + description: 需要 platform:order:declaration:edit;只修改预计到货时间与平台预估体积,客户下单和仓库实测体积不受影响。 + parameters: + - $ref: '#/components/parameters/UnifiedOrderId' + - $ref: '#/components/parameters/UnifiedTenantId' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/OrderPlanningUpdateRequest'} + responses: + '200': {description: 成功返回新订单版本;版本冲突时响应体业务码为 A0409} + '403': {description: 无权限或非平台身份} /api/merchant/v1/orders: get: summary: 客户统一订单列表 @@ -585,9 +600,17 @@ components: expectedVersion: {type: integer, minimum: 0} customerRemark: {type: string, nullable: true, maxLength: 1000} expectedArrivalAt: {type: string, format: date-time, nullable: true} + estimatedVolumeCbm: {type: number, minimum: 0.0001, nullable: true, description: 平台运营预估体积,独立于客户下单货物体积与仓库实测体积} channelCode: {type: string, maxLength: 64, description: 与 channelName 同时传;变更时重新校验价格渠道,入仓码保持不变} channelName: {type: string, maxLength: 128} cargoes: {type: array, minItems: 1, maxItems: 500, items: {$ref: '#/components/schemas/OrderDeclarationCargo'}} + OrderPlanningUpdateRequest: + type: object + required: [expectedVersion] + properties: + expectedVersion: {type: integer, minimum: 0} + expectedArrivalAt: {type: string, format: date-time, nullable: true} + estimatedVolumeCbm: {type: number, minimum: 0.0001, nullable: true} OrderDeclarationCargo: type: object required: [itemNo, cargoName, packageCount, totalQuantity, lengthCm, widthCm, heightCm, grossWeightKg, volumeCbm] @@ -668,6 +691,7 @@ components: type: object properties: orderId: {type: integer, format: int64} + orderVersion: {type: integer, nullable: true, description: 列表行编辑使用的订单乐观锁版本,不作为业务编号展示} tenantId: {type: integer, format: int64} orderNo: {type: string} mainStatus: {$ref: '#/components/schemas/UnifiedMainStatus'} @@ -690,7 +714,8 @@ components: totalPackageCount: {type: integer, nullable: true, description: 无入仓事实时为当前订单有效货物箱数合计;存在入仓业务单元后为该单元履约总箱数,拆分后不以父单原始箱数覆盖} recommendedPalletType: {type: string, nullable: true, description: 当前无列表级权威事实时返回 null,不得返回固定托盘类型} locationCodes: {type: string, nullable: true} - expectedVolumeCbm: {type: number, nullable: true, description: Order 客户申报货物体积合计,不得与实测体积混用} + estimatedVolumeCbm: {type: number, nullable: true, description: 平台运营填写的预估体积,不得与客户下单或实测体积混用} + 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} diff --git a/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java b/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java index d7b229429..378d9164b 100644 --- a/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java @@ -27,7 +27,7 @@ class OrderDeclarationUpdateContractTest { assertThat(OrderDeclarationDtos.UpdateRequest.class.getRecordComponents()) .extracting(component -> component.getName()) .containsExactly("expectedVersion", "customerRemark", "expectedArrivalAt", - "channelCode", "channelName", "cargoes") + "estimatedVolumeCbm", "channelCode", "channelName", "cargoes") .doesNotContain("receivingWarehouseId", "receivingWarehouseName", "destinationWarehouseId", "destinationWarehouseName", "inboundCode", "orderNo"); assertThat(OrderDeclarationDtos.Cargo.class.getRecordComponents()) @@ -38,17 +38,33 @@ class OrderDeclarationUpdateContractTest { "measurementReviewStatus", "feeReviewStatus"); } + @Test + void planningEndpointOnlyAcceptsVersionArrivalAndOperatorEstimate() throws Exception { + var method = PlatformUnifiedOrderController.class.getDeclaredMethod("updatePlanning", + com.baiyue.common.security.AuthenticatedUser.class, long.class, long.class, + OrderDeclarationDtos.PlanningUpdateRequest.class); + assertThat(method.getAnnotation(PutMapping.class).value()).containsExactly("/{orderId}/planning"); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:order:declaration:edit')"); + assertThat(OrderDeclarationDtos.PlanningUpdateRequest.class.getRecordComponents()) + .extracting(component -> component.getName()) + .containsExactly("expectedVersion", "expectedArrivalAt", "estimatedVolumeCbm"); + } + @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"); + .containsKeys("/api/platform/v1/orders/{orderId}/declaration", + "/api/platform/v1/orders/{orderId}/planning"); 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(row).containsKeys("orderVersion", "estimatedVolumeCbm", "expectedVolumeCbm", + "actualVolumeCbm", "expectedArrivalAt", "channelChanged"); assertThat(map(row.get("actualVolumeCbm")).get("description").toString()).contains("仅 Firstleg 实测"); + assertThat(map(row.get("estimatedVolumeCbm")).get("description").toString()).contains("平台运营"); assertThat(source).contains("ORDER_DECLARATION_UPDATED_V1", "platform:order:declaration:edit", "A0409", "/internal/firstleg/v1/inbounds/order-channel-changes/validate", "/internal/firstleg/v1/pricing/order-channel-changes/validate", diff --git a/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java index d688ee976..ea15e8241 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java @@ -43,7 +43,7 @@ class OrderDeclarationUpdateServiceTest { 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); + any(), any(), any(), anyString(), anyString(), anyBoolean())).thenReturn(1); when(repository.updateDeclarationCargo(eq(100L), eq(200L), any())).thenReturn(1); var request = request(3, "NEW", "新渠道", "新备注"); @@ -54,7 +54,7 @@ class OrderDeclarationUpdateServiceTest { assertThat(response.version()).isEqualTo(4); assertThat(response.channelChanged()).isTrue(); assertThat(response.changes()).extracting(OrderDeclarationDtos.FieldChange::field) - .contains("channelCode", "channelName", "customerRemark", "expectedArrivalAt", + .contains("channelCode", "channelName", "customerRemark", "expectedArrivalAt", "estimatedVolumeCbm", "cargoName", "packageCount", "totalQuantity", "lengthCm", "grossWeightKg"); verify(pricingChannelGuard).requireAvailable(100L, 31L, "NEW", "FBA"); verify(repository).insertHistory(any()); @@ -71,7 +71,7 @@ class OrderDeclarationUpdateServiceTest { request(3, "OLD", "旧渠道", "备注"))) .isInstanceOf(BizException.class).hasMessageContaining("版本已变化"); verify(repository, never()).updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(), - any(), any(), any(), any(), anyBoolean()); + any(), any(), any(), any(), any(), anyBoolean()); } @Test @@ -80,7 +80,7 @@ class OrderDeclarationUpdateServiceTest { 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); + any(), any(), any(), anyBoolean())).thenReturn(1); when(repository.updateDeclarationCargo(anyLong(), anyLong(), any())).thenReturn(1); when(json.writeValueAsString(any())).thenReturn("{\"schemaVersion\":1}"); @@ -98,7 +98,7 @@ class OrderDeclarationUpdateServiceTest { 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); + any(), any(), any(), anyBoolean())).thenReturn(1); when(repository.updateDeclarationCargo(anyLong(), anyLong(), any())).thenReturn(1); when(json.writeValueAsString(any())).thenReturn("{\"schemaVersion\":1}"); @@ -125,7 +125,34 @@ class OrderDeclarationUpdateServiceTest { verify(fulfillmentChannelGuard).requireEligible(order, 4L, "NEW"); verify(pricingChannelGuard, never()).requireOrderChannelChangeEligible(any(), anyLong(), anyString(), anyString()); verify(repository, never()).updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(), - any(), any(), any(), any(), anyBoolean()); + any(), any(), any(), any(), any(), anyBoolean()); + } + + @Test + void updatesPlanningFieldsWithCurrentDeclarationSnapshotAndVersion() { + OrderEntity order = order(OrderStatus.SUBMITTED, 3); + order.setExpectedArrivalAt(LocalDateTime.of(2026, 9, 10, 8, 30)); + order.setEstimatedVolumeCbm(new BigDecimal("0.25")); + 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), + eq("旧备注"), eq(LocalDateTime.of(2026, 9, 12, 16, 0)), eq(new BigDecimal("0.50")), + eq("OLD"), eq("旧渠道"), eq(false))).thenReturn(1); + when(repository.updateDeclarationCargo(eq(100L), eq(200L), any())).thenReturn(1); + + var response = service.updatePlanning(100L, 200L, 700L, "平台操作员", + new OrderDeclarationDtos.PlanningUpdateRequest(3, + LocalDateTime.of(2026, 9, 12, 16, 0), new BigDecimal("0.50"))); + + assertThat(response.version()).isEqualTo(4); + assertThat(response.changes()).extracting(OrderDeclarationDtos.FieldChange::field) + .containsExactlyInAnyOrder("expectedArrivalAt", "estimatedVolumeCbm"); + verify(pricingChannelGuard, never()).requireAvailable(anyLong(), anyLong(), anyString(), anyString()); + verify(fulfillmentChannelGuard, never()).requireEligible(any(), anyLong(), anyString()); + verify(repository, never()).updateDeclarationCargo(anyLong(), anyLong(), any()); + assertThat(cargo.getCargoName()).isEqualTo("旧品名"); + assertThat(cargo.getPackageCount()).isEqualTo(10); } private OrderEntity order(OrderStatus status, int version) { @@ -156,6 +183,7 @@ class OrderDeclarationUpdateServiceTest { "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)); + LocalDateTime.of(2026, 9, 15, 10, 30), new BigDecimal("2.5"), + channelCode, channelName, List.of(cargo)); } }