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. */ /** Detail payload for the product-level order detail. */
public record UnifiedInboundDetail( public record UnifiedInboundDetail(
UnifiedInboundSummary summary, UnifiedInboundSummary summary,
List<TimelineEvent> timeline List<TimelineEvent> timeline,
List<CustomerTransportFragment> transportFragments
) { ) {
public record TimelineEvent(String source, String eventType, String fromStatus, String toStatus, public record TimelineEvent(String source, String eventType, String fromStatus, String toStatus,
String action, String remark, Long operatorId, String operatorName, String action, String remark, Long operatorId, String operatorName,
@@ -10,6 +10,29 @@ import java.util.List;
/** 统一订单按当前有效装柜明细批量读取柜次船期。 */ /** 统一订单按当前有效装柜明细批量读取柜次船期。 */
@Mapper @Mapper
public interface UnifiedInboundTransportMapper { 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(""" @Select("""
<script> <script>
SELECT DISTINCT i.inbound_id, i.account_id, p.container_booking_id, 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); 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) { public Map<Long, CurrentContainerTransport> findTransports(Map<Long, Long> tenantByInbound) {
if (tenantByInbound.isEmpty()) return Map.of(); if (tenantByInbound.isEmpty()) return Map.of();
Map<Long, List<CurrentContainerTransport>> grouped = transports Map<Long, List<CurrentContainerTransport>> grouped = transports
@@ -83,8 +83,11 @@ public class UnifiedInboundQueryService {
? operations.findTransport(tenantId, inbound.getId()) : null; ? operations.findTransport(tenantId, inbound.getId()) : null;
boolean customerFeeConfirmationRequired = pricing.customerFeeConfirmationRequiredMap( boolean customerFeeConfirmationRequired = pricing.customerFeeConfirmationRequiredMap(
tenantId, Map.of(inbound.getId(), inbound.getTenantId())).getOrDefault(inbound.getId(), false); 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, 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) { private List<UnifiedInboundSummary> summaries(List<InboundOrderEntity> records) {
@@ -1484,6 +1484,25 @@ components:
required: true required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'} schema: {type: string, pattern: '^[1-9][0-9]*$'}
schemas: 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: InboundHoldCommand:
type: object type: object
required: [expectedVersion, reason] required: [expectedVersion, reason]
@@ -1660,7 +1679,12 @@ components:
nullable: true nullable: true
UnifiedInboundDetail: UnifiedInboundDetail:
type: object type: object
required: [transportFragments]
properties: properties:
transportFragments:
type: array
description: 仅当前客户当前订单已完成实装片段的柜级运输事实,按入仓码和柜次分别返回,不包含跳拣来源或其他客户
items: {$ref: '#/components/schemas/CustomerTransportFragment'}
summary: {$ref: '#/components/schemas/UnifiedInboundSummary'} summary: {$ref: '#/components/schemas/UnifiedInboundSummary'}
timeline: timeline:
type: array type: array
@@ -59,4 +59,19 @@ class UnifiedInboundOpenApiContractTest {
private static List<String> stringList(Object value) { private static List<String> stringList(Object value) {
return (List<String>) 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)) assertThatThrownBy(() -> new UnifiedInboundQueryService().page(query))
.isInstanceOf(BizException.class); .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, record Summary(Inbound inbound, String mainStatus, String nextAction, Boolean customerFeeConfirmationRequired,
Preload preload, Loading loading, Delivery delivery) {} 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, record Inbound(Long id, Long tenantId, Long orderId, String orderNo, String inboundCode,
String processStatus, String orderStatusSnapshot, String measureReviewStatus, String processStatus, String orderStatusSnapshot, String measureReviewStatus,
Integer version, Integer version,
@@ -48,7 +48,13 @@ public final class UnifiedOrderResponse {
List<String> memberInboundCodes) {} List<String> memberInboundCodes) {}
public record Detail(Row summary, OrderDetailResponse order, 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, public record MeasurementSummary(String reviewStatus, BigDecimal actualGrossWeightKg,
BigDecimal actualVolumeCbm, BigDecimal chargeableWeightKg, BigDecimal actualVolumeCbm, BigDecimal chargeableWeightKg,
@@ -153,7 +153,13 @@ public class UnifiedOrderQueryService {
} }
timeline.sort(Comparator.comparing(UnifiedOrderResponse.TimelineEvent::occurredAt, timeline.sort(Comparator.comparing(UnifiedOrderResponse.TimelineEvent::occurredAt,
Comparator.nullsLast(Comparator.naturalOrder()))); 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, private PageResult<UnifiedOrderResponse.Row> pageFromFirstleg(Long tenantId, UnifiedOrderQuery query,
@@ -910,6 +910,25 @@ components:
scheme: bearer scheme: bearer
bearerFormat: JWT bearerFormat: JWT
schemas: 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: CargoLabelBindingListEnvelope:
type: object type: object
required: [code, data] required: [code, data]
@@ -1110,7 +1129,12 @@ components:
data: {$ref: '#/components/schemas/UnifiedOrderDetail'} data: {$ref: '#/components/schemas/UnifiedOrderDetail'}
UnifiedOrderDetail: UnifiedOrderDetail:
type: object type: object
required: [transportFragments]
properties: properties:
transportFragments:
type: array
description: 仅当前客户当前订单已完成实装片段的柜级运输事实,按入仓码和柜次分别返回,不包含跳拣来源或其他客户
items: {$ref: '#/components/schemas/CustomerTransportFragment'}
summary: {$ref: '#/components/schemas/UnifiedOrderRow'} summary: {$ref: '#/components/schemas/UnifiedOrderRow'}
order: order:
type: object type: object
@@ -128,4 +128,19 @@ class UnifiedOrderApiContractTest {
Map<String, Object> content = stringMap(response.get("content")); Map<String, Object> content = stringMap(response.get("content"));
return stringMap(stringMap(content.get("application/json")).get("schema")); 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.findNotes(10001L, 77L)).thenReturn(List.of());
when(orders.findHistory(10001L, 77L)).thenReturn(List.of()); when(orders.findHistory(10001L, 77L)).thenReturn(List.of());
FirstlegUnifiedOrderClient.Detail fulfillment = 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)); when(firstleg.detail(eq("internal-token"), any())).thenReturn(ApiResponse.success(fulfillment));
var group = new FirstlegColoadGroupClient.GroupSnapshot( var group = new FirstlegColoadGroupClient.GroupSnapshot(
700L, "CLG-700", "LOCKED", 11L, "MATSON", 900L, 1, 700L, "CLG-700", "LOCKED", 11L, "MATSON", 900L, 1,
@@ -710,7 +710,7 @@ class UnifiedOrderQueryServiceTest {
null, 9L, "平台操作员", LocalDateTime.of(2026, 9, 8, 10, 0)); null, 9L, "平台操作员", LocalDateTime.of(2026, 9, 8, 10, 0));
var summary = summary("WAIT_PALLETIZE", "PALLETIZE_DROPSHIP"); var summary = summary("WAIT_PALLETIZE", "PALLETIZE_DROPSHIP");
when(firstleg.detail(eq("internal-token"), any())).thenReturn(ApiResponse.success( 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); var result = service.detail(10001L, 77L, false);
@@ -761,12 +761,23 @@ class UnifiedOrderQueryServiceTest {
when(orders.findParties(10001L, 77L)).thenReturn(List.of()); when(orders.findParties(10001L, 77L)).thenReturn(List.of());
when(orders.findNotes(10001L, 77L)).thenReturn(List.of()); when(orders.findNotes(10001L, 77L)).thenReturn(List.of());
when(orders.findHistory(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())) when(firstleg.detail(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(fulfillmentDetail)); .thenReturn(ApiResponse.success(fulfillmentDetail));
var result = service.detail(10001L, 77L, false); 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().mainStatus()).isEqualTo("LOADED");
assertThat(result.summary().internalOrderStatus()).isEqualTo(OrderStatus.APPROVED); assertThat(result.summary().internalOrderStatus()).isEqualTo(OrderStatus.APPROVED);
assertThat(result.summary().inboundId()).isEqualTo(88L); assertThat(result.summary().inboundId()).isEqualTo(88L);
@@ -887,7 +898,7 @@ class UnifiedOrderQueryServiceTest {
when(orders.findNotes(10001L, 77L)).thenReturn(List.of()); when(orders.findNotes(10001L, 77L)).thenReturn(List.of());
when(orders.findHistory(10001L, 77L)).thenReturn(List.of()); when(orders.findHistory(10001L, 77L)).thenReturn(List.of());
when(firstleg.detail(eq("internal-token"), any())).thenReturn( 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)); assertServiceUnavailable(() -> service.detail(10001L, 77L, false));
} }