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
@@ -0,0 +1,17 @@
package com.baiyue.firstleg.dto;
import java.time.LocalDateTime;
import java.util.List;
/** Customer-safe, read-only transport facts for one actually loaded inbound/container fragment. */
public record CustomerTransportFragment(String inboundCode, String cabinetNo, String containerNo,
List<Node> nodes) {
public record Node(String standardNode, Integer nodeSequence, String providerCode,
LocalDateTime estimatedAt, LocalDateTime actualAt,
String locationCode, String locationName) {}
public record Row(Long inboundId, Long containerBookingId, String inboundCode, String cabinetNo,
String containerNo, String standardNode, Integer nodeSequence, String providerCode,
LocalDateTime estimatedAt, LocalDateTime actualAt,
String locationCode, String locationName) {}
}
@@ -6,7 +6,8 @@ import java.util.List;
/** Detail payload for the product-level order detail. */
public record UnifiedInboundDetail(
UnifiedInboundSummary summary,
List<TimelineEvent> timeline
List<TimelineEvent> timeline,
List<CustomerTransportFragment> transportFragments
) {
public record TimelineEvent(String source, String eventType, String fromStatus, String toStatus,
String action, String remark, Long operatorId, String operatorName,
@@ -10,6 +10,29 @@ import java.util.List;
/** 统一订单按当前有效装柜明细批量读取柜次船期。 */
@Mapper
public interface UnifiedInboundTransportMapper {
@Select("""
SELECT owned.inbound_id, owned.container_booking_id, owned.inbound_code,
b.cabinet_no, b.container_no, n.standard_node, n.node_sequence, n.provider_code,
n.estimated_at, n.actual_at, n.location_code, n.location_name
FROM (
SELECT DISTINCT o.id AS inbound_id, p.container_booking_id, o.inbound_code
FROM fl_inbound_order o
JOIN fl_loading_plan_item i ON i.inbound_id=o.id AND i.account_id=o.tenant_id
JOIN fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status='COMPLETED'
JOIN fl_loading_pick_task t ON t.loading_plan_id=p.id AND t.task_status='COMPLETED'
JOIN fl_loading_pick_item picked ON picked.pick_task_id=t.id
AND picked.loading_plan_item_id=i.id
WHERE o.tenant_id=#{tenantId} AND o.order_id=#{orderId} AND o.deleted=0
AND i.deleted=0 AND picked.item_status IN ('PICKED','PARTIAL')
AND picked.picked_package_count>0
) owned
JOIN fl_container_booking b ON b.id=owned.container_booking_id
LEFT JOIN fl_carrier_schedule_node_fact n ON n.container_booking_id=owned.container_booking_id
ORDER BY owned.inbound_id, owned.container_booking_id, n.node_sequence, n.id
""")
List<com.baiyue.firstleg.dto.CustomerTransportFragment.Row> findCustomerTransportNodes(
@Param("tenantId") long tenantId, @Param("orderId") long orderId);
@Select("""
<script>
SELECT DISTINCT i.inbound_id, i.account_id, p.container_booking_id,
@@ -86,6 +86,24 @@ public class UnifiedInboundReadRepository {
return findTransports(Map.of(inboundId, tenantId)).get(inboundId);
}
public List<com.baiyue.firstleg.dto.CustomerTransportFragment> findCustomerTransportFragments(
long tenantId, long orderId) {
var grouped = new java.util.LinkedHashMap<String,
List<com.baiyue.firstleg.dto.CustomerTransportFragment.Row>>();
transports.findCustomerTransportNodes(tenantId, orderId).forEach(row ->
grouped.computeIfAbsent(row.inboundId() + ":" + row.containerBookingId(),
ignored -> new java.util.ArrayList<>()).add(row));
return grouped.values().stream().map(rows -> {
var first = rows.get(0);
var nodes = rows.stream().filter(row -> row.standardNode() != null)
.map(row -> new com.baiyue.firstleg.dto.CustomerTransportFragment.Node(
row.standardNode(), row.nodeSequence(), row.providerCode(), row.estimatedAt(),
row.actualAt(), row.locationCode(), row.locationName())).toList();
return new com.baiyue.firstleg.dto.CustomerTransportFragment(first.inboundCode(),
first.cabinetNo(), first.containerNo(), nodes);
}).toList();
}
public Map<Long, CurrentContainerTransport> findTransports(Map<Long, Long> tenantByInbound) {
if (tenantByInbound.isEmpty()) return Map.of();
Map<Long, List<CurrentContainerTransport>> grouped = transports
@@ -83,8 +83,11 @@ public class UnifiedInboundQueryService {
? operations.findTransport(tenantId, inbound.getId()) : null;
boolean customerFeeConfirmationRequired = pricing.customerFeeConfirmationRequiredMap(
tenantId, Map.of(inbound.getId(), inbound.getTenantId())).getOrDefault(inbound.getId(), false);
boolean hasTransportScope = includePhaseThree || inbounds.findByOrderIds(tenantId, List.of(orderId))
.stream().anyMatch(item -> requiresPhaseThreeSummary(mainStatus(item.getProcessStatus())));
return new UnifiedInboundDetail(summary(inbound, detail, preload, pick, evidence, deliveryAt,
transport, null, null, customerFeeConfirmationRequired), timeline);
transport, null, null, customerFeeConfirmationRequired), timeline,
hasTransportScope ? operations.findCustomerTransportFragments(tenantId, orderId) : List.of());
}
private List<UnifiedInboundSummary> summaries(List<InboundOrderEntity> records) {
@@ -1484,6 +1484,25 @@ components:
required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'}
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}
InboundHoldCommand:
type: object
required: [expectedVersion, reason]
@@ -1660,7 +1679,12 @@ components:
nullable: true
UnifiedInboundDetail:
type: object
required: [transportFragments]
properties:
transportFragments:
type: array
description: 仅当前客户当前订单已完成实装片段的柜级运输事实,按入仓码和柜次分别返回,不包含跳拣来源或其他客户
items: {$ref: '#/components/schemas/CustomerTransportFragment'}
summary: {$ref: '#/components/schemas/UnifiedInboundSummary'}
timeline:
type: array
@@ -59,4 +59,19 @@ class UnifiedInboundOpenApiContractTest {
private static List<String> stringList(Object value) {
return (List<String>) value;
}
@Test
void transportFragmentsDocumentCustomerSafeFactsWithoutInternalIdentifiers() throws Exception {
Map<String,Object> document=stringMap(new Yaml().load(new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8)));
var schemas=stringMap(stringMap(document.get("components")).get("schemas"));
var detail=stringMap(schemas.get("UnifiedInboundDetail"));
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");
}
}
@@ -0,0 +1,88 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.firstleg.mapper.UnifiedInboundTransportMapper;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class CustomerTransportFragmentPersistenceTest {
private JdbcTemplate jdbc;
private UnifiedInboundReadRepository repository;
@BeforeEach
void setup() throws Exception {
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:customer-transport-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT,tenant_id BIGINT,order_id BIGINT,inbound_code VARCHAR,deleted INT)");
jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,loading_plan_id BIGINT,inbound_id BIGINT,account_id BIGINT,deleted INT)");
jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT,container_booking_id BIGINT,plan_status VARCHAR)");
jdbc.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT,task_status VARCHAR)");
jdbc.execute("CREATE TABLE fl_loading_pick_item(pick_task_id BIGINT,loading_plan_item_id BIGINT,item_status VARCHAR,picked_package_count INT)");
jdbc.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR)");
jdbc.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,node_sequence INT,provider_code VARCHAR,estimated_at TIMESTAMP,actual_at TIMESTAMP,location_code VARCHAR,location_name VARCHAR)");
jdbc.update("INSERT INTO fl_inbound_order VALUES(1,100,77,'NY-1',0),(2,100,77,'NY-2',0),(3,200,88,'OTHER',0),(4,100,99,'OWN-OTHER-ORDER',0)");
jdbc.update("INSERT INTO fl_loading_plan VALUES(10,50,'COMPLETED'),(11,51,'COMPLETED'),(12,52,'LOADING')");
jdbc.update("INSERT INTO fl_container_booking VALUES(50,'D1','TSTU0000001'),(51,'D2','TSTU0000002'),(52,'D3','TSTU0000003')");
jdbc.update("INSERT INTO fl_loading_pick_task VALUES(20,10,'COMPLETED'),(21,11,'COMPLETED'),(22,12,'LOADING')");
jdbc.update("INSERT INTO fl_loading_plan_item VALUES(101,10,1,100,0),(102,10,1,100,0),(103,10,2,100,0),(104,10,3,200,0),(105,10,4,100,0),(106,11,2,100,0),(107,12,2,100,0)");
jdbc.update("INSERT INTO fl_loading_pick_item VALUES(20,101,'PARTIAL',1),(20,102,'PICKED',1),(20,103,'SKIPPED',0),(20,104,'PICKED',1),(20,105,'PICKED',1),(21,106,'PICKED',1),(22,107,'PARTIAL',1)");
jdbc.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(1,50,'AT_ORIGIN_PORT',20,'MANUAL',NULL,'2026-10-05 09:00:00','CNSZX','深圳'),(2,50,'DEPARTED_ORIGIN_PORT',40,'MATSON','2026-10-07 08:00:00',NULL,NULL,NULL),(3,51,'AT_ORIGIN_PORT',20,'MATSON',NULL,'2026-10-06 10:00:00',NULL,NULL)");
var config = new MybatisConfiguration();
config.setMapUnderscoreToCamelCase(true); config.addMapper(UnifiedInboundTransportMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config);
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(UnifiedInboundTransportMapper.class);
repository = new UnifiedInboundReadRepository(); ReflectionTestUtils.setField(repository, "transports", mapper);
}
@Test
void separatesSplitFragmentsAndKeepsActualPortEntryDistinctFromEstimatedSailing() {
var fragments = repository.findCustomerTransportFragments(100,77);
assertThat(fragments).hasSize(2);
assertThat(fragments.get(0).inboundCode()).isEqualTo("NY-1");
assertThat(fragments.get(0).nodes()).hasSize(2); // two SKU memberships do not duplicate facts
assertThat(fragments.get(0).nodes().get(0).standardNode()).isEqualTo("AT_ORIGIN_PORT");
assertThat(fragments.get(0).nodes().get(0).actualAt()).isEqualTo(LocalDateTime.of(2026,10,5,9,0));
assertThat(fragments.get(0).nodes().get(1).actualAt()).isNull();
assertThat(fragments.get(0).nodes().get(1).estimatedAt()).isNotNull();
assertThat(fragments.get(1).cabinetNo()).isEqualTo("D2");
assertThat(fragments.get(1).nodes()).singleElement().satisfies(n ->
assertThat(n.actualAt()).isEqualTo(LocalDateTime.of(2026,10,6,10,0)));
}
@Test
void sameCustomerOtherOrderAndOtherCustomerCannotReadThisOrdersFragments() {
assertThat(repository.findCustomerTransportFragments(200,77)).isEmpty();
assertThat(repository.findCustomerTransportFragments(100,88)).isEmpty();
assertThat(repository.findCustomerTransportFragments(100,99)).singleElement()
.satisfies(fragment -> assertThat(fragment.inboundCode()).isEqualTo("OWN-OTHER-ORDER"));
}
@Test
void wholeSkippedAndDeletedMembershipsCannotGrantTransportAccess() {
jdbc.update("UPDATE fl_loading_pick_item SET item_status='SKIPPED',picked_package_count=0 WHERE loading_plan_item_id IN(101,102,106)");
assertThat(repository.findCustomerTransportFragments(100,77)).isEmpty();
jdbc.update("UPDATE fl_loading_pick_item SET item_status='PICKED',picked_package_count=1 WHERE loading_plan_item_id=101");
jdbc.update("UPDATE fl_loading_plan_item SET deleted=1 WHERE id=101");
assertThat(repository.findCustomerTransportFragments(100,77)).isEmpty();
}
@Test
void oneInboundInMultipleCompletedCabinetsRetainsEachFactAndEmptyNodeLists() {
jdbc.update("UPDATE fl_loading_plan_item SET inbound_id=1 WHERE id=106");
jdbc.update("DELETE FROM fl_carrier_schedule_node_fact WHERE container_booking_id=51");
var fragments=repository.findCustomerTransportFragments(100,77);
assertThat(fragments).extracting(f -> f.inboundCode()).containsExactly("NY-1","NY-1");
assertThat(fragments.get(1).nodes()).isEmpty();
}
}
@@ -138,4 +138,26 @@ class UnifiedInboundQueryServiceTest {
assertThatThrownBy(() -> new UnifiedInboundQueryService().page(query))
.isInstanceOf(BizException.class);
}
@Test
void remainingWaitSplitFragmentDoesNotHideOlderLoadedFragmentsOfSameOrder() {
var inbounds = mock(InboundOrderRepository.class);
var operations = mock(UnifiedInboundReadRepository.class);
var pricing = mock(FbaPricingRepository.class);
var service = new UnifiedInboundQueryService();
ReflectionTestUtils.setField(service,"inbounds",inbounds);
ReflectionTestUtils.setField(service,"operations",operations);
ReflectionTestUtils.setField(service,"pricing",pricing);
var remaining = new InboundOrderEntity();
remaining.setId(88L); remaining.setOrderId(99L); remaining.setTenantId(10001L);
remaining.setProcessStatus("WAIT_SPLIT");
var loaded = new InboundOrderEntity(); loaded.setProcessStatus("LOADED");
when(inbounds.findByOrderId(10001L,99L)).thenReturn(remaining);
when(inbounds.findByOrderIds(10001L,List.of(99L))).thenReturn(List.of(remaining,loaded));
when(inbounds.detail(10001L,remaining)).thenReturn(new InboundOrderDetail(null,null,null,null,null,List.of()));
var fragment = new com.baiyue.firstleg.dto.CustomerTransportFragment("NY-1","D1","TSTU0000001",List.of());
when(operations.findCustomerTransportFragments(10001L,99L)).thenReturn(List.of(fragment));
assertThat(service.detail(10001L,99L).transportFragments()).containsExactly(fragment);
org.mockito.Mockito.verify(operations).findCustomerTransportFragments(10001L,99L);
}
}
@@ -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));
}