feat(orders): expose customer-owned loaded transport facts

This commit is contained in:
2026-10-05 01:06:11 +08:00
parent 5ad3fec449
commit b000b056d1
15 changed files with 286 additions and 9 deletions
@@ -48,7 +48,11 @@ public interface FirstlegUnifiedOrderClient {
record Summary(Inbound inbound, String mainStatus, String nextAction, Boolean customerFeeConfirmationRequired,
Preload preload, Loading loading, Delivery delivery) {}
record Detail(Summary summary, List<Timeline> timeline) {}
record Detail(Summary summary, List<Timeline> timeline, List<TransportFragment> transportFragments) {}
record TransportFragment(String inboundCode, String cabinetNo, String containerNo, List<TransportNode> nodes) {}
record TransportNode(String standardNode, Integer nodeSequence, String providerCode,
LocalDateTime estimatedAt, LocalDateTime actualAt,
String locationCode, String locationName) {}
record Inbound(Long id, Long tenantId, Long orderId, String orderNo, String inboundCode,
String processStatus, String orderStatusSnapshot, String measureReviewStatus,
Integer version,
@@ -48,7 +48,13 @@ public final class UnifiedOrderResponse {
List<String> memberInboundCodes) {}
public record Detail(Row summary, OrderDetailResponse order,
List<TimelineEvent> timeline) {}
List<TimelineEvent> timeline,
List<TransportFragment> transportFragments) {}
public record TransportFragment(String inboundCode, String cabinetNo, String containerNo,
List<TransportNode> nodes) {}
public record TransportNode(String standardNode, Integer nodeSequence, String providerCode,
LocalDateTime estimatedAt, LocalDateTime actualAt,
String locationCode, String locationName) {}
public record MeasurementSummary(String reviewStatus, BigDecimal actualGrossWeightKg,
BigDecimal actualVolumeCbm, BigDecimal chargeableWeightKg,
@@ -153,7 +153,13 @@ public class UnifiedOrderQueryService {
}
timeline.sort(Comparator.comparing(UnifiedOrderResponse.TimelineEvent::occurredAt,
Comparator.nullsLast(Comparator.naturalOrder())));
return new UnifiedOrderResponse.Detail(summary, orderDetail, timeline);
var fragments = inbound == null ? List.<UnifiedOrderResponse.TransportFragment>of()
: inbound.transportFragments().stream().map(fragment -> new UnifiedOrderResponse.TransportFragment(
fragment.inboundCode(), fragment.cabinetNo(), fragment.containerNo(),
fragment.nodes().stream().map(node -> new UnifiedOrderResponse.TransportNode(
node.standardNode(), node.nodeSequence(), node.providerCode(), node.estimatedAt(),
node.actualAt(), node.locationCode(), node.locationName())).toList())).toList();
return new UnifiedOrderResponse.Detail(summary, orderDetail, timeline, fragments);
}
private PageResult<UnifiedOrderResponse.Row> pageFromFirstleg(Long tenantId, UnifiedOrderQuery query,
@@ -910,6 +910,25 @@ components:
scheme: bearer
bearerFormat: JWT
schemas:
CustomerTransportFragment:
type: object
required: [nodes]
properties:
inboundCode: {type: string, nullable: true}
cabinetNo: {type: string, nullable: true}
containerNo: {type: string, nullable: true}
nodes: {type: array, items: {$ref: '#/components/schemas/CustomerTransportNode'}}
CustomerTransportNode:
type: object
properties:
standardNode: {type: string, description: 标准船节点;进港为AT_ORIGIN_PORT,不表示开航}
nodeSequence: {type: integer}
providerCode: {type: string, nullable: true}
estimatedAt: {type: string, format: date-time, nullable: true}
actualAt: {type: string, format: date-time, nullable: true, description: 仅实际时间表示实际发生,不能用预计时间替代}
locationCode: {type: string, nullable: true}
locationName: {type: string, nullable: true}
CargoLabelBindingListEnvelope:
type: object
required: [code, data]
@@ -1110,7 +1129,12 @@ components:
data: {$ref: '#/components/schemas/UnifiedOrderDetail'}
UnifiedOrderDetail:
type: object
required: [transportFragments]
properties:
transportFragments:
type: array
description: 仅当前客户当前订单已完成实装片段的柜级运输事实,按入仓码和柜次分别返回,不包含跳拣来源或其他客户
items: {$ref: '#/components/schemas/CustomerTransportFragment'}
summary: {$ref: '#/components/schemas/UnifiedOrderRow'}
order:
type: object
@@ -128,4 +128,19 @@ class UnifiedOrderApiContractTest {
Map<String, Object> content = stringMap(response.get("content"));
return stringMap(stringMap(content.get("application/json")).get("schema"));
}
@Test
void transportFragmentsDocumentCustomerSafeFactsWithoutInternalIdentifiers() throws Exception {
Map<String,Object> document=stringMap(new Yaml().load(new ClassPathResource("openapi/order-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8)));
var schemas=stringMap(stringMap(document.get("components")).get("schemas"));
var detail=stringMap(schemas.get("UnifiedOrderDetail"));
assertThat(stringList(detail.get("required"))).contains("transportFragments");
var fragments=stringMap(stringMap(detail.get("properties")).get("transportFragments"));
assertThat(stringMap(fragments.get("items")).get("$ref")).isEqualTo("#/components/schemas/CustomerTransportFragment");
assertThat(stringMap(stringMap(schemas.get("CustomerTransportFragment")).get("properties")))
.containsOnlyKeys("inboundCode","cabinetNo","containerNo","nodes");
assertThat(stringMap(stringMap(schemas.get("CustomerTransportNode")).get("properties")))
.containsOnlyKeys("standardNode","nodeSequence","providerCode","estimatedAt","actualAt","locationCode","locationName");
}
}
@@ -488,7 +488,7 @@ class UnifiedOrderQueryServiceTest {
when(orders.findNotes(10001L, 77L)).thenReturn(List.of());
when(orders.findHistory(10001L, 77L)).thenReturn(List.of());
FirstlegUnifiedOrderClient.Detail fulfillment =
new FirstlegUnifiedOrderClient.Detail(summary("LOADING", null), List.of());
new FirstlegUnifiedOrderClient.Detail(summary("LOADING", null), List.of(), List.of());
when(firstleg.detail(eq("internal-token"), any())).thenReturn(ApiResponse.success(fulfillment));
var group = new FirstlegColoadGroupClient.GroupSnapshot(
700L, "CLG-700", "LOCKED", 11L, "MATSON", 900L, 1,
@@ -710,7 +710,7 @@ class UnifiedOrderQueryServiceTest {
null, 9L, "平台操作员", LocalDateTime.of(2026, 9, 8, 10, 0));
var summary = summary("WAIT_PALLETIZE", "PALLETIZE_DROPSHIP");
when(firstleg.detail(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new FirstlegUnifiedOrderClient.Detail(summary, List.of(timeline))));
new FirstlegUnifiedOrderClient.Detail(summary, List.of(timeline), List.of())));
var result = service.detail(10001L, 77L, false);
@@ -761,12 +761,23 @@ 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());
var fulfillmentDetail = new FirstlegUnifiedOrderClient.Detail(summary("LOADED", null), List.of());
var portEntry = new FirstlegUnifiedOrderClient.TransportNode("AT_ORIGIN_PORT",20,"MANUAL",
null,LocalDateTime.of(2026,10,5,9,0),"CNSZX","深圳");
var estimatedSailing = new FirstlegUnifiedOrderClient.TransportNode("DEPARTED_ORIGIN_PORT",40,"MATSON",
LocalDateTime.of(2026,10,7,8,0),null,null,null);
var fulfillmentDetail = new FirstlegUnifiedOrderClient.Detail(summary("LOADED", null), List.of(),
List.of(new FirstlegUnifiedOrderClient.TransportFragment("NY-1","D1","TSTU0000001",List.of(portEntry)),
new FirstlegUnifiedOrderClient.TransportFragment("NY-2","D2","TSTU0000002",List.of(estimatedSailing))));
when(firstleg.detail(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(fulfillmentDetail));
var result = service.detail(10001L, 77L, false);
assertThat(result.transportFragments()).hasSize(2);
assertThat(result.transportFragments().get(0).nodes().get(0).standardNode()).isEqualTo("AT_ORIGIN_PORT");
assertThat(result.transportFragments().get(0).nodes().get(0).actualAt()).isNotNull();
assertThat(result.transportFragments().get(1).nodes().get(0).actualAt()).isNull();
assertThat(result.transportFragments().get(1).nodes().get(0).estimatedAt()).isNotNull();
assertThat(result.summary().mainStatus()).isEqualTo("LOADED");
assertThat(result.summary().internalOrderStatus()).isEqualTo(OrderStatus.APPROVED);
assertThat(result.summary().inboundId()).isEqualTo(88L);
@@ -887,7 +898,7 @@ class UnifiedOrderQueryServiceTest {
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(null, List.of())));
ApiResponse.success(new FirstlegUnifiedOrderClient.Detail(null, List.of(), List.of())));
assertServiceUnavailable(() -> service.detail(10001L, 77L, false));
}