Merge remote-tracking branch 'origin/test' into test
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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 '客户要求平台上门提货的预约时间',
|
||||
|
||||
@@ -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}
|
||||
|
||||
+19
-3
@@ -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",
|
||||
|
||||
+35
-7
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user