Merge remote-tracking branch 'origin/test' into test

This commit is contained in:
2026-09-03 11:59:15 +08:00
17 changed files with 260 additions and 30 deletions
@@ -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');
@@ -155,15 +155,13 @@ public class MeasurementBatchService {
: requestedMode == null ? findDefaultCoefficientRule(command.tenantId(), customerId) : null;
String coefficientMode = coefficientRule != null ? "MULTIPLY" : requestedMode;
boolean useDimensionRule = "ADD".equals(coefficientMode);
// The box-dimension strategy bills against the matched carton tier.
// This is important when a measured carton is smaller than a standard
// carton: use the configured outer dimensions, rather than adding the
// edge allowance to each measured side.
BigDecimal addition = weighted.edgeAdditionCm();
boolean hasMatchedAddition = useDimensionRule && weighted.boxType() != null;
BigDecimal outerLength = hasMatchedAddition ? weighted.outerLengthCm() : source.lengthCm();
BigDecimal outerWidth = hasMatchedAddition ? weighted.outerWidthCm() : source.widthCm();
BigDecimal outerHeight = hasMatchedAddition ? weighted.outerHeightCm() : source.heightCm();
// ADD 只从匹配箱规取得加边值,外尺寸仍以本次实测尺寸为基准,
// 不能把箱规边界尺寸当成本次货物的实际外尺寸。
BigDecimal outerLength = hasMatchedAddition ? source.lengthCm().add(addition) : source.lengthCm();
BigDecimal outerWidth = hasMatchedAddition ? source.widthCm().add(addition) : source.widthCm();
BigDecimal outerHeight = hasMatchedAddition ? source.heightCm().add(addition) : source.heightCm();
boolean hasOuterOverride = source.outerLengthCm() != null
|| source.outerWidthCm() != null
|| source.outerHeightCm() != null;
@@ -15,7 +15,7 @@ paths:
- name: customerId
in: query
required: false
schema: {type: integer, format: int64, minimum: 1}
schema: {type: string, pattern: '^[1-9][0-9]*$'}
- {name: ruleCode, in: query, schema: {type: string, maxLength: 50}}
responses:
'200':
@@ -484,7 +484,7 @@ class FbaPricingServiceTest {
// 试算重复用例的摘要由服务算法固定;其余用例在首次调用后才返回该记录。
value.setRequestHash("FEE_SEND".equals(operation)
? "3be2051b9bc4c06a163cb3099fd7b3f828b51307cc54d3cfc232c11353c61046"
: "8aa8a9861d4cac0c6001bb902754c152c6211ba57e1fad9160b1caef651e3d65");
: "1b874a4f469151f236aa6086835f3cac22cca5cb4a1b90444f08b27b76e8a3c4");
return value;
}
@@ -52,6 +52,7 @@ import static org.mockito.Mockito.when;
class InboundMeasurementServiceTest {
@MockBean private InboundMeasurementRepository repository;
@MockBean private MeasurementBatchService batchService;
@MockBean private FbaMasterDataService fbaMasterDataService;
@MockBean private OrderCargoClient orderCargoClient;
@MockBean private MeasurementImageClient imageClient;
@MockBean private WarehousePalletLocationClient warehousePallets;
@@ -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<OrderDeclarationDtos.UpdateResponse> 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 = "父子订单与柜次数量摘要")
@@ -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,
@@ -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,
@@ -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;
@@ -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<OrderEntity>()
.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)
@@ -0,0 +1,56 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.FirstlegUnifiedOrderClient;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/** 客户申报资料只允许在待审核、待测量和待分柜阶段修改。 */
@Service
public class OrderDeclarationEditGuard {
private static final Set<String> EDITABLE_MAIN_STATUSES = Set.of("WAIT_MEASURE", "WAIT_SPLIT");
@Resource private FirstlegUnifiedOrderClient firstleg;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public void requireEditable(OrderEntity order) {
if (order.getStatus() != OrderStatus.APPROVED) return;
try {
ApiResponse<List<FirstlegUnifiedOrderClient.Summary>> response = firstleg.summaries(internalToken,
new FirstlegUnifiedOrderClient.BatchRequest(order.getTenantId(), List.of(order.getId())));
if (response == null) throw unavailable();
if (!response.successful()) {
ResultCode code = ResultCode.of(response.code());
String message = response.message() == null || response.message().isBlank()
? code.message() : response.message();
throw new BizException(code, message);
}
List<FirstlegUnifiedOrderClient.Summary> rows = response.data();
if (rows == null || rows.size() != 1 || rows.get(0) == null || rows.get(0).inbound() == null
|| !Objects.equals(rows.get(0).inbound().orderId(), order.getId())) {
throw unavailable();
}
String mainStatus = rows.get(0).mainStatus();
if (!EDITABLE_MAIN_STATUSES.contains(mainStatus)) {
throw new BizException(ResultCode.CONFLICT, "订单已进入装柜或后续阶段,不能修改客户申报资料");
}
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw unavailable();
}
}
private BizException unavailable() {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程履约状态校验服务暂时不可用");
}
}
@@ -37,8 +37,48 @@ public class OrderDeclarationUpdateService {
@Resource private OrderRepository repository;
@Resource private OrderPricingChannelGuard pricingChannelGuard;
@Resource private OrderFulfillmentChannelChangeGuard fulfillmentChannelGuard;
@Resource private OrderDeclarationEditGuard declarationEditGuard;
@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<OrderDeclarationDtos.FieldChange> 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<OrderDeclarationDtos.Cargo> 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,
@@ -51,6 +91,7 @@ public class OrderDeclarationUpdateService {
if (!Objects.equals(order.getVersion(), request.expectedVersion())) {
throw new BizException(ResultCode.CONFLICT, "订单版本已变化,请刷新详情后重试");
}
declarationEditGuard.requireEditable(order);
List<OrderCargoEntity> current = repository.findCargo(tenantId, orderId);
Map<Integer, OrderCargoEntity> byItemNo = new HashMap<>();
@@ -83,6 +124,7 @@ public class OrderDeclarationUpdateService {
List<OrderDeclarationDtos.FieldChange> 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 +136,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 +172,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 +198,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<OrderDeclarationDtos.FieldChange> changes, int version) {
@@ -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(),
+1
View File
@@ -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 '客户要求平台上门提货的预约时间',
+26 -1
View File
@@ -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}
@@ -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<String, Object> 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<String, Object> schemas = map(map(document.get("components")).get("schemas"));
Map<String, Object> 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",
@@ -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));
}
}