feat(lastleg): query commercial sources independently of fulfillment types

This commit is contained in:
2026-10-09 16:12:02 +08:00
parent c4a7c2a22d
commit 3b5454fb99
13 changed files with 156 additions and 19 deletions
@@ -37,7 +37,10 @@ public final class LastlegOrderRequests {
Boolean detained,
LocalDateTime createdFrom,
LocalDateTime createdTo,
SailingStage sailingStage
SailingStage sailingStage,
@Size(max = 64) String businessType,
@Size(max = 128) String businessOrderNo,
@Size(max = 128) String externalBookingNo
) {
public Query {
if (page == null || page == 0) page = 1L;
@@ -74,6 +74,9 @@ public record LastlegOrderSummary(
LocalDateTime createdAt,
LocalDateTime updatedAt,
@Schema(description = "同一主单关联的全部换标子单及各自状态,按创建时间排序")
List<RelabelSubOrder> relabelApplications
List<RelabelSubOrder> relabelApplications,
BusinessSource businessSource
) {
@Schema(description = "从正式单据关联读取的商业来源;与履约类型 orderType 独立")
public record BusinessSource(String businessType, String label, String businessOrderNo, String externalBookingNo) { }
}
@@ -10,7 +10,7 @@ import java.util.Arrays;
final class LastlegExportColumns {
private LastlegExportColumns() { }
private static final Map<String, String> DEFINITIONS = Map.ofEntries(
Map.entry("LAST_MILE_ORDER_LIST", "orderNo:尾程单号,orderTypeName:订单类型,inboundCode:入仓码,sourceOrderNo:头程单号,customerName:货主,overseasWarehouseName:海外仓,transferWarehouseName:转仓,transferWarehouseCode:转仓编码,fbaWarehouseCode:FBA仓库,containerNo:集装箱号,containerNode:货柜节点,abnormal:是否异常,detained:是否暂扣,palletCount:托数:INTEGER,packageCount:客户预报箱数:INTEGER,receivedPackageCount:实收箱数:INTEGER,packageDifference:差异箱数:INTEGER,volumeCbm:体积(m³):DECIMAL,productQuantity:数量:INTEGER,volumeWeightKg:体积重量:DECIMAL,estimatedArrivalAt:预计到港时间:DATETIME,actualArrivalAt:实际到港时间:DATETIME,estimatedWarehouseAt:预计到仓:DATETIME,inboundOrderNo:入库单号,relabelOrderNo:换标单号,relabelStatus:换标状态,palletMergeFlag:合托标记,currentStatus:主流程状态,subOrderStatus:子单状态,privateRemark:备注(仅自己),customerRemark:备注(客户),warehouseRemark:备注(仓库)"),
Map.entry("LAST_MILE_ORDER_LIST", "orderNo:尾程单号,orderTypeName:履约类型,businessSource.label:业务来源,businessSource.businessOrderNo:关联商业单号,businessSource.externalBookingNo:外接预订号,inboundCode:入仓码,sourceOrderNo:来源订单号,customerName:货主,overseasWarehouseName:海外仓,transferWarehouseName:转仓,transferWarehouseCode:转仓编码,fbaWarehouseCode:FBA仓库,containerNo:集装箱号,containerNode:货柜节点,abnormal:是否异常,detained:是否暂扣,palletCount:托数:INTEGER,packageCount:客户预报箱数:INTEGER,receivedPackageCount:实收箱数:INTEGER,packageDifference:差异箱数:INTEGER,volumeCbm:体积(m³):DECIMAL,productQuantity:数量:INTEGER,volumeWeightKg:体积重量:DECIMAL,estimatedArrivalAt:预计到港时间:DATETIME,actualArrivalAt:实际到港时间:DATETIME,estimatedWarehouseAt:预计到仓:DATETIME,inboundOrderNo:入库单号,relabelOrderNo:换标单号,relabelStatus:换标状态,palletMergeFlag:合托标记,currentStatus:主流程状态,subOrderStatus:子单状态,privateRemark:备注(仅自己),customerRemark:备注(客户),warehouseRemark:备注(仓库)"),
Map.entry("OVERSEAS_INVENTORY_BALANCE", "customerName:客户,overseasWarehouseCode:海外仓编码,overseasWarehouseName:海外仓,destinationWarehouseCode:目的仓编码,destinationWarehouseName:目的仓,inboundOrderNo:入库单号,inboundCode:入仓码,skuCode:SKU,productNameZh:中文品名,productNameEn:英文品名,totalPackages:总箱数:INTEGER,availablePackages:可用箱数:INTEGER,holdPackages:暂扣箱数:INTEGER,allocatedPackages:已分配箱数:INTEGER,pickedPackages:已拣箱数:INTEGER,inTransitPackages:在途箱数:INTEGER,receivingPackages:收货中箱数:INTEGER,procurementPackages:采购箱数:INTEGER"),
Map.entry("OVERSEAS_INVENTORY_LEDGER", "occurredAt:发生时间:DATETIME,customerName:客户,destinationWarehouseCode:目的仓编码,inboundOrderNo:入库单号,inboundCode:入仓码,businessType:业务类型,businessOrderNo:业务单号,outboundOrderNo:出库单号,palletNo:托盘号,batchNo:批次号,statusFrom:原状态,statusTo:新状态,beforePackages:变动前箱数:INTEGER,deltaPackages:变动箱数:INTEGER,afterPackages:变动后箱数:INTEGER,remark:备注"),
Map.entry("OVERSEAS_QUOTE_LIST", "name:报价名称,warehouseCode:合作仓编码,warehouseName:合作仓名称,providerName:报价方,currency:币种,effectiveStart:生效开始:DATE,effectiveEnd:生效结束:DATE,versionCode:版本号,status:状态,updatedAt:更新时间:DATETIME"),
@@ -16,6 +16,25 @@ import java.math.BigDecimal;
@Mapper
public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
// Correlate both order identity and customer; never infer a business source from remarks.
String POOL_SOURCE_WHERE = " FROM ll_fleet_pool_order_link l WHERE l.lastleg_order_id=ll_order.id " +
"AND l.customer_id=ll_order.customer_id AND ll_order.order_type='WAREHOUSE_TRANSFER'";
String BUSINESS_TYPE_SQL = "COALESCE((SELECT l.business_type" + POOL_SOURCE_WHERE + "),ll_order.order_type)";
String BUSINESS_ORDER_NO_SQL = "COALESCE((SELECT l.business_order_no" + POOL_SOURCE_WHERE +
"),CASE WHEN ll_order.order_type='LOCAL_LOOSEBOARD' THEN ll_order.source_order_no END)";
String EXTERNAL_BOOKING_NO_SQL = "(SELECT b.booking_no FROM ll_fleet_external_booking b " +
"WHERE b.customer_id=ll_order.customer_id AND b.incoming_no=ll_order.source_order_no " +
"AND ll_order.order_type='WAREHOUSE_TRANSFER')";
@Select("<script>SELECT ll_order.id AS orderId," + BUSINESS_TYPE_SQL + " AS businessType," +
BUSINESS_ORDER_NO_SQL + " AS businessOrderNo," + EXTERNAL_BOOKING_NO_SQL + " AS externalBookingNo," +
"EXISTS(SELECT 1" + POOL_SOURCE_WHERE + ") AS poolSource FROM ll_order WHERE ll_order.id IN " +
"<foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<BusinessSourceRow> findBusinessSources(@Param("orderIds") List<Long> orderIds);
record BusinessSourceRow(Long orderId, String businessType, String businessOrderNo,
String externalBookingNo, boolean poolSource) { }
// Manual node snapshots replace container nodes permanently for the order.
String SAILING_STAGE_FILTER_SQL = "(ll_order.order_type='FIRST_LEG' AND " +
"(CASE WHEN COALESCE(ll_order.manual_node_override,FALSE)=TRUE THEN " +
@@ -100,11 +100,12 @@ public class LastlegOrderService {
Map<Long, String> dispatchStatuses = dispatchStatuses(page.getRecords());
Map<Long, LastlegOrderMapper.OrderWeightRow> weights = orderWeights(page.getRecords());
Map<Long, List<com.baiyue.lastleg.dto.RelabelSubOrder>> relabels = relabelSubOrders(page.getRecords());
Map<Long, LastlegOrderSummary.BusinessSource> businessSources = businessSources(page.getRecords());
return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()),
StringUtils.hasText(order.getSourceContainerBookingId())
? containerNodes.get(order.getSourceContainerBookingId()) : null,
dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"), weights.get(order.getId()),
relabels.getOrDefault(order.getId(), List.of()))).toList(),
relabels.getOrDefault(order.getId(), List.of()), businessSources.get(order.getId()))).toList(),
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
@@ -360,7 +361,8 @@ public class LastlegOrderService {
.sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList();
return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId()),
containerNode(order), dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED"),
orderWeights(List.of(order)).get(order.getId()), relabelSubOrders(List.of(order)).getOrDefault(order.getId(), List.of())), manualNodes, cargo, history.stream()
orderWeights(List.of(order)).get(order.getId()), relabelSubOrders(List.of(order)).getOrDefault(order.getId(), List.of()),
businessSources(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream()
.map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(),
value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream()
.map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(),
@@ -549,6 +551,9 @@ public class LastlegOrderService {
.or().apply("EXISTS (SELECT 1 FROM ll_relabel_application r WHERE r.lastleg_order_id=ll_order.id " +
"AND r.customer_id=ll_order.customer_id AND r.relabel_no LIKE CONCAT('%',{0},'%'))", relabelNo))
.eq(StringUtils.hasText(query.orderType()), LastlegOrder::getOrderType, query.orderType())
.apply(StringUtils.hasText(query.businessType()), LastlegOrderMapper.BUSINESS_TYPE_SQL + "={0}", query.businessType())
.apply(StringUtils.hasText(query.businessOrderNo()), LastlegOrderMapper.BUSINESS_ORDER_NO_SQL + " LIKE CONCAT('%',{0},'%')", query.businessOrderNo())
.apply(StringUtils.hasText(query.externalBookingNo()), LastlegOrderMapper.EXTERNAL_BOOKING_NO_SQL + " LIKE CONCAT('%',{0},'%')", query.externalBookingNo())
.apply(query.sailingStage() != null, LastlegOrderMapper.SAILING_STAGE_FILTER_SQL,
query.sailingStage() == LastlegOrderRequests.SailingStage.AFTER_DEPARTURE ? 1 : 0)
.like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode())
@@ -623,10 +628,23 @@ public class LastlegOrderService {
return rows == null ? Map.of() : rows.stream().collect(Collectors.groupingBy(com.baiyue.lastleg.dto.RelabelSubOrder::lastlegOrderId));
}
private Map<Long, LastlegOrderSummary.BusinessSource> businessSources(List<LastlegOrder> orders) {
if (orders.isEmpty()) return Map.of();
return orderMapper.findBusinessSources(orders.stream().map(LastlegOrder::getId).toList()).stream()
.collect(Collectors.toMap(LastlegOrderMapper.BusinessSourceRow::orderId, row -> {
String label = "US_TRUCK".equals(row.businessType()) ? "本土卡车" : LastlegOrderType.labelOf(row.businessType());
if (row.poolSource()) label += " · 拼车需求池";
else if ("LOCAL_LOOSEBOARD".equals(row.businessType())) label += " · 独立下单";
else if (StringUtils.hasText(row.externalBookingNo())) label += " · 外接来货";
return new LastlegOrderSummary.BusinessSource(row.businessType(), label, row.businessOrderNo(), row.externalBookingNo());
}));
}
private LastlegOrderSummary summary(LastlegOrder o, List<LastlegOrderMapper.PalletMergeRelationRow> relations,
String containerNode, String dispatchStatus,
LastlegOrderMapper.OrderWeightRow weight,
List<com.baiyue.lastleg.dto.RelabelSubOrder> relabelApplications) {
List<com.baiyue.lastleg.dto.RelabelSubOrder> relabelApplications,
LastlegOrderSummary.BusinessSource businessSource) {
List<LastlegOrderMapper.PalletMergeRelationRow> related = relations == null ? List.of() : relations;
return new LastlegOrderSummary(o.getId(), displayOrderNo(o), o.getOrderType(),
LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(),
@@ -644,7 +662,7 @@ public class LastlegOrderService {
o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null),
related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedInboundNo).filter(Objects::nonNull).distinct().toList(),
o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(),
o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt(), relabelApplications);
o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt(), relabelApplications, businessSource);
}
private static Integer packageDifference(LastlegOrder order) {
@@ -6,7 +6,7 @@ info:
Admin 导出统一通过 POST /api/platform/v1/files/export-tasks 创建 XLSX 任务。
File 按导出类型校验原业务 export 权限,通过内部令牌分批读取 Lastleg;Provider 不经公网 Gateway。
parameters 与对应业务列表筛选一致,不包含 page/size;进销存支持 customerName。
尾程订单支持 relabelNo 和 sailingStage,库存余额按来源入库单展开,不导出内部数据库 ID。
尾程订单支持 relabelNo、sailingStage、businessType、businessOrderNo、externalBookingNo;导出履约类型、业务来源、关联商业单号和外接预订号,与列表同一投影。库存余额按来源入库单展开;所有导出均不包含内部数据库 ID。
每批最多 200 行,总行数上限 200000;车队报价及出车记录保留业务查询的 5000 条限制。
snapshotToken 绑定任务、申请人、执行版本和筛选条件,不表示数据库历史快照。
TRIP_OPERATIONS_LIST 的 costVisible 仅由 File 根据申请人的 platform:fleet:trip:list 权限写入,不能信任客户端值。
@@ -211,9 +211,12 @@ paths:
/api/platform/v1/last-leg/orders:
get:
summary: 分页查询尾程订单
description: 权限 platform:overseas-warehouse:last-mile-order:list;平台管理员省略 customerId 时查询全部客户。containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁;grossWeightKg/netWeightKg 由头程冻结货物明细聚合。
description: 权限 platform:overseas-warehouse:last-mile-order:list;平台管理员省略 customerId 时查询全部客户。containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁;grossWeightKg/netWeightKg 由头程冻结货物明细聚合。businessSource 返回 businessType、label、businessOrderNo、externalBookingNo;商业类型优先取同客户同尾程单的池来源桥,独立散板取自身来源单号,普通转运不推断为散板。orderType 保留履约类型;无关联单号为空。
security: [{ bearerAuth: [] }]
parameters:
- { name: businessType, in: query, description: 商业类型;本土散板同时覆盖独立和池来源,仓储转运排除已关联散板或卡车商业单, schema: { type: string, maxLength: 64 } }
- { name: businessOrderNo, in: query, description: 正式关联商业单号包含匹配,不解析备注, schema: { type: string, maxLength: 128 } }
- { name: externalBookingNo, in: query, description: 正式外接预订号包含匹配,不解析备注, schema: { type: string, maxLength: 128 } }
- { name: sailingStage, in: query, description: '可选开船阶段。仅匹配头程订单类型 FIRST_LEG;未传不限制类型。按实际柜级节点序号>=40判断开船后;人工节点覆盖后仅按该票 ACTUAL_DEPARTURE 非空判断,不回退柜级。无实际开船事实归开船前。', schema: { type: string, enum: [BEFORE_DEPARTURE, AFTER_DEPARTURE] } }
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
@@ -233,7 +236,7 @@ paths:
/api/platform/v1/last-leg/orders/{id}:
get:
summary: 查询尾程订单详情
description: 权限 platform:overseas-warehouse:last-mile-order:list;返回订单、货物、状态历史、费用明细和操作记录。订单中的 containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁;grossWeightKg/netWeightKg 由头程冻结货物明细聚合。
description: 权限 platform:overseas-warehouse:last-mile-order:list;返回订单、货物、状态历史、费用明细和操作记录。订单中的 containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁;grossWeightKg/netWeightKg 由头程冻结货物明细聚合。businessSource 返回 businessType、label、businessOrderNo、externalBookingNo;商业类型优先取同客户同尾程单的池来源桥,独立散板取自身来源单号,普通转运不推断为散板。orderType 保留履约类型;无关联单号为空。
security: [{ bearerAuth: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
@@ -1168,6 +1171,9 @@ paths:
description: 权限 merchant:overseas-warehouse:last-mile-order:list;列表权限包含详情只读,客户范围只取认证 Token。
security: [{ bearerAuth: [] }]
parameters:
- { name: businessType, in: query, description: 商业类型;本土散板同时覆盖独立和池来源,仓储转运排除已关联散板或卡车商业单, schema: { type: string, maxLength: 64 } }
- { name: businessOrderNo, in: query, description: 正式关联商业单号包含匹配,不解析备注, schema: { type: string, maxLength: 128 } }
- { name: externalBookingNo, in: query, description: 正式外接预订号包含匹配,不解析备注, schema: { type: string, maxLength: 128 } }
- { name: sailingStage, in: query, description: '可选开船阶段。仅匹配头程订单类型 FIRST_LEG;未传不限制类型。按实际柜级节点序号>=40判断开船后;人工节点覆盖后仅按该票 ACTUAL_DEPARTURE 非空判断,不回退柜级。无实际开船事实归开船前。', schema: { type: string, enum: [BEFORE_DEPARTURE, AFTER_DEPARTURE] } }
responses: { '200': { description: 当前客户海外尾程订单分页 }, '403': { description: 缺少海外尾程订单查看权限 } }
/api/merchant/v1/last-leg/orders/{id}: