This commit is contained in:
2026-08-17 19:51:54 +08:00
parent cddd23e4c7
commit 45b51415cf
84 changed files with 1278 additions and 158 deletions
@@ -44,7 +44,8 @@ public interface FirstlegInboundClient {
Long carrierId, String carrierName, String taxMode, String pricingTaxMode, String containerMode,
Boolean customsInspection, String destinationType, Long destinationWarehouseId,
String destinationCode, String destinationName, String publicRemark,
String expressCompanyName, String expressTrackingNo, Integer totalPackageCount
String expressCompanyName, String expressTrackingNo, Integer totalPackageCount,
String pickupMethod, LocalDateTime requestedPickupAt
) {
}
}
@@ -47,14 +47,14 @@ public interface FirstlegUnifiedOrderClient {
Long customerId, String customerName, Long warehouseId, String warehouseName,
Long deliveryWarehouseId, String deliveryWarehouseName, String channelName,
Integer totalPackageCount, String locationCodes,
LocalDateTime expectedArrivalAt, String feeReviewStatus,
LocalDateTime expectedArrivalAt, LocalDateTime requestedPickupAt, String feeReviewStatus,
String companyName, String businessType, String taxMode, String containerMode,
Boolean customsInspection, String destinationType, String destinationCode,
BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg, BigDecimal actualVolumeCbm,
BigDecimal volumeWeightKg, BigDecimal chargeableVolumeCbm, Integer palletCount,
BigDecimal chargeableWeightKg, LocalDateTime measureCompletedAt,
LocalDateTime inboundedAt, String expressCompanyName, String createdByName,
String loadingStatus, String containerNo, String cabinetNo, String vesselName, String voyageNo,
String containerNo, String cabinetNo, String vesselName, String voyageNo,
LocalDateTime sailingAt, LocalDateTime portArrivalAt,
String expressTrackingNo, LocalDateTime createdAt, LocalDateTime updatedAt) {}
record Preload(String status, Integer version, Integer itemCount, LocalDateTime updatedAt) {}
@@ -53,6 +53,8 @@ public final class OrderRequests {
@Schema(description = "Cargo lines / 货物明细", requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty @Valid List<Cargo> cargoes,
@NotBlank @Pattern(regexp = "SELF_EXPRESS|PICKUP|SELF_TRUCK") String pickupMethod,
@Schema(description = "Customer-requested pickup appointment; only applies to PICKUP / 客户要求平台上门提货的预约时间,仅 PICKUP 适用")
LocalDateTime requestedPickupAt,
@NotNull Long receivingWarehouseId,
@NotBlank @Size(max = 128) String receivingWarehouseName,
@NotBlank @Size(max = 64) String channelCode,
@@ -71,6 +73,23 @@ public final class OrderRequests {
@Size(max = 64) String shipmentId,
@Size(max = 64) String referenceId
) {
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
String destinationCountryCode, Long destinationWarehouseId,
String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark,
Party sender, Party receiver, List<Cargo> cargoes, String pickupMethod,
Long receivingWarehouseId, String receivingWarehouseName, String channelCode,
String channelName, Long carrierId, String carrierName, String taxMode,
String pricingTaxMode, String containerMode, Boolean customsInspection,
String destinationType, String amazonWarehouseCode, String shipmentId,
String referenceId) {
this(customerId, customerName, transportMode, tradeTerm, destinationCountryCode,
destinationWarehouseId, destinationWarehouseName, expectedShipDate, customerRemark,
sender, receiver, cargoes, pickupMethod, null, receivingWarehouseId,
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
shipmentId, referenceId);
}
@AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空")
public boolean isCarrierSnapshotComplete() {
return (carrierId == null) == (carrierName == null || carrierName.isBlank());
@@ -33,7 +33,7 @@ public final class UnifiedOrderResponse {
MeasurementSummary measurement, PreloadSummary preload,
LoadingSummary loading, DeliverySummary delivery, TransportSummary transport,
String customerRemark, String latestInternalNote,
LocalDateTime createdAt, LocalDateTime updatedAt
LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime requestedPickupAt
) {}
public record OrderRelationSummary(String role, Long rootOrderId, Long parentOrderId,
@@ -51,7 +51,7 @@ public final class UnifiedOrderResponse {
Integer expectedCount, Integer pickedCount, Integer evidenceCount,
String sealNo, LocalDateTime updatedAt) {}
public record DeliverySummary(String status, LocalDateTime completedAt, String trackingNo) {}
public record TransportSummary(String loadingStatus, String containerNo, String cabinetNo,
public record TransportSummary(String containerNo, String cabinetNo,
String vesselName, String voyageNo, LocalDateTime sailingAt,
LocalDateTime portArrivalAt) {}
public record TimelineEvent(String source, String eventType, String mainStatus, String action,
@@ -33,6 +33,7 @@ public class OrderEntity extends BaseEntity {
private LocalDate expectedShipDate;
private String customerRemark;
private String pickupMethod;
private LocalDateTime requestedPickupAt;
private Long receivingWarehouseId;
private String receivingWarehouseName;
private String receivingWarehouseAddress;
@@ -208,10 +208,10 @@ public class OrderRepository {
return cargoMapper.selectObjs(wrapper).stream().map(value -> ((Number) value).longValue()).toList();
}
public List<OrderCargoEntity> findCargoByOrderIds(long tenantId, List<Long> orderIds) {
public List<OrderCargoEntity> findCargoByOrderIds(Long tenantId, List<Long> orderIds) {
if (orderIds.isEmpty()) return List.of();
return cargoMapper.selectList(new LambdaQueryWrapper<OrderCargoEntity>()
.eq(OrderCargoEntity::getTenantId, tenantId)
.eq(tenantId != null, OrderCargoEntity::getTenantId, tenantId)
.in(OrderCargoEntity::getOrderId, orderIds)
.orderByAsc(OrderCargoEntity::getOrderId)
.orderByAsc(OrderCargoEntity::getItemNo));
@@ -288,6 +288,9 @@ public class OrderApplicationService {
if ("PICKUP".equals(request.pickupMethod()) && request.sender() == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须填写发货信息");
}
if ("PICKUP".equals(request.pickupMethod()) && request.requestedPickupAt() == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须填写预约提货时间");
}
if ("PRIVATE".equals(request.destinationType()) && request.receiver() == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "私人地址必须填写收货信息");
}
@@ -421,7 +424,9 @@ public class OrderApplicationService {
order.setDestinationWarehouseName(snapshots.destination() == null ? request.destinationWarehouseName()
: snapshots.destination().warehouse().warehouseName());
order.setExpectedShipDate(request.expectedShipDate()); order.setCustomerRemark(request.customerRemark());
order.setPickupMethod(request.pickupMethod()); order.setReceivingWarehouseId(request.receivingWarehouseId());
order.setPickupMethod(request.pickupMethod());
order.setRequestedPickupAt("PICKUP".equals(request.pickupMethod()) ? request.requestedPickupAt() : null);
order.setReceivingWarehouseId(request.receivingWarehouseId());
order.setReceivingWarehouseName(snapshots.receiving().warehouse().warehouseName());
order.setReceivingWarehouseAddress(formatAddress(snapshots.receiving()));
order.setReceivingTime(formatReceivingTime(snapshots.receiving()));
@@ -524,7 +529,8 @@ public class OrderApplicationService {
order.getTaxMode(), order.getPricingTaxMode(),
order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(),
order.getDestinationWarehouseId(), order.getAmazonWarehouseCode(), order.getDestinationWarehouseName(),
order.getCustomerRemark(), order.getExpressCompany(), order.getExpressTrackingNo(), totalPackageCount);
order.getCustomerRemark(), order.getExpressCompany(), order.getExpressTrackingNo(), totalPackageCount,
order.getPickupMethod(), order.getRequestedPickupAt());
}
private void prepareDraftInbound(OrderEntity order, OrderRequests.SaveRequest request) {
FirstlegInboundClient client = firstlegProvider == null ? null : firstlegProvider.getIfAvailable();
@@ -187,7 +187,7 @@ public class UnifiedOrderQueryService {
string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(),
null, null, null, null, null,
order.getCustomerRemark(), latestInternalNote,
order.getCreatedAt(), order.getUpdatedAt());
order.getCreatedAt(), order.getUpdatedAt(), order.getRequestedPickupAt());
}
FirstlegUnifiedOrderClient.Inbound inbound = summary.inbound();
return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(),
@@ -204,7 +204,7 @@ public class UnifiedOrderQueryService {
inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(), measurement(inbound),
preload(summary.preload()), loading(summary.loading()),
delivery(summary.delivery()), transport(inbound), order.getCustomerRemark(), latestInternalNote,
order.getCreatedAt(), order.getUpdatedAt());
order.getCreatedAt(), order.getUpdatedAt(), inbound.requestedPickupAt());
}
private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary, OrderEntity order,
@@ -226,7 +226,7 @@ public class UnifiedOrderQueryService {
inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order == null ? null : order.getShipmentId(),
order == null ? null : order.getReferenceId(), measurement(inbound), preload(summary.preload()), loading(summary.loading()),
delivery(summary.delivery()), transport(inbound), order == null ? null : order.getCustomerRemark(), latestInternalNote,
inbound.createdAt(), inbound.updatedAt());
inbound.createdAt(), inbound.updatedAt(), inbound.requestedPickupAt());
}
private UnifiedOrderResponse.MeasurementSummary measurement(FirstlegUnifiedOrderClient.Inbound inbound) {
@@ -248,8 +248,8 @@ public class UnifiedOrderQueryService {
value.status(), value.completedAt(), value.trackingNo());
}
private UnifiedOrderResponse.TransportSummary transport(FirstlegUnifiedOrderClient.Inbound inbound) {
return new UnifiedOrderResponse.TransportSummary(inbound.loadingStatus(), inbound.containerNo(),
inbound.cabinetNo(), inbound.vesselName(), inbound.voyageNo(), inbound.sailingAt(), inbound.portArrivalAt());
return new UnifiedOrderResponse.TransportSummary(inbound.containerNo(), inbound.cabinetNo(),
inbound.vesselName(), inbound.voyageNo(), inbound.sailingAt(), inbound.portArrivalAt());
}
private String orderMainStatus(OrderStatus status) {
+1
View File
@@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
expected_ship_date DATE NULL COMMENT '预计发运日期',
customer_remark VARCHAR(1000) NULL COMMENT '客户可见备注',
pickup_method VARCHAR(24) NULL COMMENT '取货方式',
requested_pickup_at DATETIME(3) NULL COMMENT '客户要求平台上门提货的预约时间',
receiving_warehouse_id BIGINT UNSIGNED NULL COMMENT '国内收货仓ID',
receiving_warehouse_name VARCHAR(128) NULL COMMENT '国内收货仓快照',
receiving_warehouse_address VARCHAR(512) NULL COMMENT '国内收货仓完整地址快照',
@@ -433,6 +433,7 @@ components:
companyName: {type: string, nullable: true}
businessType: {type: string, nullable: true}
pickupMethod: {type: string, nullable: true}
requestedPickupAt: {type: string, format: date-time, nullable: true, description: Customer-requested pickup appointment from the pickup snapshot}
taxMode: {type: string, nullable: true}
containerMode: {type: string, nullable: true}
customsInspection: {type: boolean, nullable: true}
@@ -509,7 +510,6 @@ components:
type: object
nullable: true
properties:
loadingStatus: {type: string, nullable: true}
containerNo: {type: string, nullable: true}
cabinetNo: {type: string, nullable: true}
vesselName: {type: string, nullable: true}
@@ -53,7 +53,7 @@ class UnifiedOrderApiContractTest {
assertThat(stringMap(row.get("properties"))).containsKeys(
"orderId", "mainStatus", "inboundId", "inboundVersion", "measurement", "preload",
"loading", "delivery", "transport", "customerRemark", "latestInternalNote",
"expectedArrivalAt", "feeReviewStatus", "companyName", "businessType", "pickupMethod",
"expectedArrivalAt", "requestedPickupAt", "feeReviewStatus", "companyName", "businessType", "pickupMethod",
"taxMode", "containerMode", "customsInspection", "destinationType", "destinationCode",
"totalPalletCount", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg",
"measurementCompletedAt", "inboundCompletedAt", "expressCompany", "expressTrackingNo",
@@ -22,6 +22,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalTime;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
@@ -40,7 +41,7 @@ class OrderApplicationServiceStateTest {
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
@Bean ObjectMapper objectMapper() { return new ObjectMapper().findAndRegisterModules(); }
}
@Test
void unapprovedTenantCanCreateDraft() {
@@ -356,7 +357,7 @@ class OrderApplicationServiceStateTest {
ReflectionTestUtils.setField(service,"repository",repository);
ReflectionTestUtils.setField(service,"eligibilityGuard",eligibility);
ReflectionTestUtils.setField(service,"masterDataGuard",mock(OrderMasterDataGuard.class));
ReflectionTestUtils.setField(service,"json",new ObjectMapper());
ReflectionTestUtils.setField(service,"json",new ObjectMapper().findAndRegisterModules());
assertThatThrownBy(() -> service.transit(10001,2,"u",1,OrderStatus.SUBMITTED,null))
.isInstanceOf(BizException.class).hasMessageContaining("重复提交");
verify(repository,never()).saveOrder(anyLong(), any());
@@ -511,7 +512,7 @@ class OrderApplicationServiceStateTest {
when(repository.nextInboundCodeSequence(any(LocalDate.class))).thenReturn(1L);
ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing);
ReflectionTestUtils.setField(service, "productImageGuard", mock(ProductImageGuard.class));
ReflectionTestUtils.setField(service, "json", new ObjectMapper());
ReflectionTestUtils.setField(service, "json", new ObjectMapper().findAndRegisterModules());
return service;
}
@@ -525,7 +526,8 @@ class OrderApplicationServiceStateTest {
null, null, null, null, null, null);
return new OrderRequests.SaveRequest(
7L, "客户", "SEA", null, "US", null, "目的仓", null, null,
party, party, List.of(cargo), "PICKUP", 8L, "收货仓", "CH", "渠道", 9L, "船司",
party, party, List.of(cargo), "PICKUP", LocalDateTime.of(2026, 8, 20, 9, 0),
8L, "收货仓", "CH", "渠道", 9L, "船司",
"BUY_DECLARATION", "TAX_INCLUDED", "LCL", false, "OVERSEAS", null, null, null);
}
@@ -539,7 +541,9 @@ class OrderApplicationServiceStateTest {
null, null, null, null, null, null);
return new OrderRequests.SaveRequest(
customerId, "当前企业", "SEA", null, "US", null, "目的仓", null, null,
sender, receiver, List.of(cargo), pickupMethod, 8L, "收货仓", "CH", "渠道", 9L, "船司",
sender, receiver, List.of(cargo), pickupMethod,
"PICKUP".equals(pickupMethod) ? LocalDateTime.of(2026, 8, 20, 9, 0) : null,
8L, "收货仓", "CH", "渠道", 9L, "船司",
"BUY_DECLARATION", "TAX_INCLUDED", "LCL", false, destinationType, null, null, null);
}
@@ -27,6 +27,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -169,6 +170,9 @@ class UnifiedOrderQueryServiceTest {
assertThat(row.inboundCode()).isEqualTo("RK-77");
assertThat(row.recommendedPalletType()).isNull();
assertThat(row.locationCodes()).isEqualTo("A0-01, B2-03");
assertThat(row.companyName()).isEqualTo("测试客户有限公司");
assertThat(row.businessType()).isEqualTo("SEA_LCL");
assertThat(row.totalPalletCount()).isEqualTo(2);
});
verify(orders, never()).unifiedPage(any(), any(), any());
}
@@ -224,8 +228,9 @@ class UnifiedOrderQueryServiceTest {
when(orders.findParties(10001L, 77L)).thenReturn(List.of());
when(orders.findNotes(10001L, 77L)).thenReturn(List.of());
when(orders.findHistory(10001L, 77L)).thenReturn(List.of());
when(firstleg.detail(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new FirstlegUnifiedOrderClient.Detail(summary("LOADED", null), List.of())));
var fulfillmentDetail = new FirstlegUnifiedOrderClient.Detail(summary("LOADED", null), List.of());
when(firstleg.detail(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(fulfillmentDetail));
var result = service.detail(10001L, 77L, false);
@@ -322,12 +327,30 @@ class UnifiedOrderQueryServiceTest {
}
private FirstlegUnifiedOrderClient.Summary summary(String status, String action) {
var inbound = new FirstlegUnifiedOrderClient.Inbound(
88L, 10001L, 77L, "FL-77", "RK-77", "INBOUNDED", "APPROVED", "APPROVED", 7,
30001L, "测试客户", 11L, "深圳仓", 22L, "ONT8", "美森", 10, "A0-01, B2-03", null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null,
LocalDateTime.of(2026, 8, 6, 10, 0), LocalDateTime.of(2026, 8, 6, 11, 0));
var inbound = mock(FirstlegUnifiedOrderClient.Inbound.class);
when(inbound.id()).thenReturn(88L);
when(inbound.tenantId()).thenReturn(10001L);
when(inbound.orderId()).thenReturn(77L);
when(inbound.orderNo()).thenReturn("FL-77");
when(inbound.inboundCode()).thenReturn("RK-77");
when(inbound.processStatus()).thenReturn("INBOUNDED");
when(inbound.orderStatusSnapshot()).thenReturn("APPROVED");
when(inbound.measureReviewStatus()).thenReturn("APPROVED");
when(inbound.version()).thenReturn(7);
when(inbound.customerId()).thenReturn(30001L);
when(inbound.customerName()).thenReturn("测试客户");
when(inbound.warehouseId()).thenReturn(11L);
when(inbound.warehouseName()).thenReturn("深圳仓");
when(inbound.deliveryWarehouseId()).thenReturn(22L);
when(inbound.deliveryWarehouseName()).thenReturn("ONT8");
when(inbound.channelName()).thenReturn("美森");
when(inbound.totalPackageCount()).thenReturn(10);
when(inbound.locationCodes()).thenReturn("A0-01, B2-03");
when(inbound.companyName()).thenReturn("测试客户有限公司");
when(inbound.businessType()).thenReturn("SEA_LCL");
when(inbound.palletCount()).thenReturn(2);
when(inbound.createdAt()).thenReturn(LocalDateTime.of(2026, 8, 6, 10, 0));
when(inbound.updatedAt()).thenReturn(LocalDateTime.of(2026, 8, 6, 11, 0));
return new FirstlegUnifiedOrderClient.Summary(inbound, status, action,
new FirstlegUnifiedOrderClient.Preload("PENDING", 1, 2, null),
new FirstlegUnifiedOrderClient.Loading("IN_PROGRESS", 3, 3, 10, 5, 1, "SEAL-1", null), null);