Merge remote-tracking branch 'origin/test' into test

# Conflicts:
#	lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java
This commit is contained in:
2026-10-20 14:35:19 +08:00
34 changed files with 1898 additions and 83 deletions
@@ -1,30 +1,65 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baiyue.lastleg.dispatch.model.DispatchBolData;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** BOL按真实明细客户逐行读取整车,不依赖已移除的主单客户头。 */
/** BOL读取原调度逐来源数量及真实预约分配,扫码进度和订单商品JOIN均不放大或缩小箱数。 */
@Mapper
public interface DispatchBolMapper {
@Select("SELECT di.customer_id AS customerId,di.destination_type AS destinationType,di.delivery_address AS deliveryAddress,di.postal_code AS postalCode," +
"COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," +
"MAX(NULLIF(di.pallet_no,'')) AS palletNo," +
"COALESCE(MAX(NULLIF(l.destination_warehouse_code,'')),MAX(NULLIF(di.fba_warehouse_code,''))," +
"MAX(NULLIF(di.destination_warehouse_code,''))) AS fbaWarehouseCode," +
"COALESCE(MAX(NULLIF(l.destination_warehouse_name,'')),MAX(NULLIF(di.destination_warehouse_name,''))) AS destinationWarehouseName," +
"COALESCE(MAX(NULLIF(l.inbound_code,'')),MAX(NULLIF(di.inbound_code,''))) AS warehouseNo," +
"GROUP_CONCAT(DISTINCT NULLIF(od.shipment_no,'') ORDER BY od.shipment_no SEPARATOR ', ') AS fbaShipmentId," +
"GROUP_CONCAT(DISTINCT NULLIF(od.fba_tracking_no,'') ORDER BY od.fba_tracking_no SEPARATOR ', ') AS poReferenceNo," +
"CASE WHEN MAX(l.id) IS NULL THEN COALESCE(MAX(di.package_count),0) " +
"WHEN COALESCE(MAX(a.picked_package_count),0)>0 THEN MAX(a.picked_package_count) " +
"ELSE COALESCE(MAX(a.planned_package_count),0) END AS packageCount " +
"FROM ll_dispatch_item di " +
"LEFT JOIN ll_inbound_plan_pallet p ON p.id=di.plan_pallet_id " +
"LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND di.return_receipt_id IS NULL AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' " +
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id " +
"LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id " +
"LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=di.customer_id " +
"WHERE di.dispatch_order_id=#{dispatchOrderId} " +
"GROUP BY di.id,di.customer_id,di.destination_type,di.delivery_address,di.postal_code,l.id ORDER BY di.id,l.id")
List<DispatchBolData.Line> findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId);
record Cargo(Long dispatchItemId,Long orderId,Long planPalletId,String customerId,String destinationType,
String deliveryAddress,String postalCode,String containerNo,String palletNo,String fbaWarehouseCode,
String destinationWarehouseName,String warehouseNo,String fbaShipmentId,String poReferenceNo,
Integer packageCount,Integer itemPackageCount,boolean originalPlan) { }
record Allocation(Long dispatchItemId,Long planPalletId,String batchKey,String customerId,String sourceKey,
String destinationKey,Integer packageCount,Long appointmentId,Long orderId,String confirmationNo) { }
/** 一行对应本车一个真实计划来源行;商品明细只聚合展示字段,原调度数量不取部分已拣数。 */
@Select("""
SELECT di.id AS dispatchItemId,
CASE WHEN di.plan_pallet_id IS NOT NULL AND di.return_receipt_id IS NULL
AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' THEN l.order_id ELSE di.order_id END AS orderId,
di.plan_pallet_id AS planPalletId,di.customer_id AS customerId,di.destination_type AS destinationType,
di.delivery_address AS deliveryAddress,di.postal_code AS postalCode,
COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo,
MAX(NULLIF(di.pallet_no,'')) AS palletNo,
COALESCE(MAX(NULLIF(l.fba_warehouse_code,'')),MAX(NULLIF(l.destination_warehouse_code,'')),
MAX(NULLIF(di.fba_warehouse_code,'')),MAX(NULLIF(di.destination_warehouse_code,''))) AS fbaWarehouseCode,
COALESCE(MAX(NULLIF(l.destination_warehouse_name,'')),MAX(NULLIF(di.destination_warehouse_name,''))) AS destinationWarehouseName,
COALESCE(MAX(NULLIF(l.inbound_code,'')),MAX(NULLIF(di.inbound_code,''))) AS warehouseNo,
GROUP_CONCAT(DISTINCT NULLIF(od.shipment_no,'') ORDER BY od.shipment_no SEPARATOR ', ') AS fbaShipmentId,
GROUP_CONCAT(DISTINCT NULLIF(od.fba_tracking_no,'') ORDER BY od.fba_tracking_no SEPARATOR ', ') AS poReferenceNo,
CASE WHEN l.id IS NULL THEN MAX(di.package_count)
WHEN di.source_stage='RECEIVED' THEN MAX(a.counted_package_count)
ELSE MAX(a.planned_package_count) END AS packageCount,
MAX(di.package_count) AS itemPackageCount,
CASE WHEN di.plan_pallet_id IS NOT NULL AND di.return_receipt_id IS NULL
AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' THEN TRUE ELSE FALSE END AS originalPlan
FROM ll_dispatch_item di
LEFT JOIN ll_inbound_plan_pallet p ON p.id=di.plan_pallet_id
LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id
LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=di.customer_id
AND di.return_receipt_id IS NULL AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER'
LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id AND l.plan_id=p.plan_id
LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id
LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=l.customer_id
WHERE di.dispatch_order_id=#{dispatchOrderId}
GROUP BY di.id,di.order_id,di.plan_pallet_id,di.customer_id,di.destination_type,di.delivery_address,
di.postal_code,di.source_stage,di.return_receipt_id,di.source_business_type,l.id,l.order_id,a.id
ORDER BY di.id,l.id,a.id
""")
List<Cargo> findWholeDispatch(@Param("dispatchOrderId") long dispatchOrderId);
/** 原batch+客户+稳定来源+目的地定位预约货物;BOOKING使用持久化order_id,绝不自行拼ORDER键。 */
@Select("""
SELECT a.dispatch_item_id AS dispatchItemId,a.plan_pallet_id AS planPalletId,a.batch_key AS batchKey,
a.customer_id AS customerId,a.source_key AS sourceKey,a.destination_key AS destinationKey,
a.package_count AS packageCount,p.id AS appointmentId,i.order_id AS orderId,p.confirmation_no AS confirmationNo
FROM ll_dispatch_appointment_allocation a
LEFT JOIN ll_inbound_fba_appointment p ON a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id)
LEFT JOIN ll_inbound_fba_appointment_item i ON i.appointment_id=p.id AND i.customer_id=a.customer_id
AND i.source_key=a.source_key AND CONCAT('FBA:',i.fba_warehouse_code)=a.destination_key
WHERE a.dispatch_id=#{dispatchOrderId} AND a.batch_key LIKE 'FBA_APPOINTMENT:%'
ORDER BY a.dispatch_item_id,a.customer_id,a.source_key,a.destination_key,a.batch_key
""")
List<Allocation> findAppointmentAllocations(@Param("dispatchOrderId") long dispatchOrderId);
}
@@ -3,13 +3,17 @@ import java.time.LocalDateTime;
import java.util.*;
/** 打印服务内部一致快照,不恢复旧排车JSON协议。 */
public record DispatchBolData(String dispatchNo,String overseasWarehouseId,String overseasWarehouseName,String carrierName,
String vehiclePlate,LocalDateTime estimatedArrivalAt,List<Line> bolLines,Map<String,String> appointmentNumbers) {
public record Line(String customerId,String destinationType,String deliveryAddress,String postalCode,String containerNo,
String vehiclePlate,LocalDateTime estimatedArrivalAt,List<Line> bolLines) {
public record Line(Long dispatchItemId,Long orderId,String customerId,String destinationType,String deliveryAddress,String postalCode,String containerNo,
String palletNo,String fbaWarehouseCode,String destinationWarehouseName,String warehouseNo,String fbaShipmentId,
String poReferenceNo,int packageCount) {
String poReferenceNo,int packageCount,String appointmentNo) {
/** 外部确认号只去首尾空白,未取得真实约号时保留空。 */
public Line { appointmentNo=appointmentNo==null || appointmentNo.isBlank()?null:appointmentNo.strip(); }
/** 目的地身份不使用客户作为分组条件。 */
public String destinationKey(){return com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(destinationType,fbaWarehouseCode,null,deliveryAddress,postalCode);}
/** 打印展示使用业务名称或真实地址。 */
public String destinationLabel(){return "PRIVATE".equals(destinationType)?deliveryAddress:destinationWarehouseName;}
public String groupKey(){return customerId+"\u0000"+destinationKey();}
/** 同目的地同约号跨客户合并,无号也可组成同目的地文档。 */
public String groupKey(){return destinationKey()+"\u0000"+Objects.requireNonNullElse(appointmentNo,"");}
}
public String appointmentFor(Line line){return appointmentNumbers.get(line.groupKey());}
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.dispatch.repository;
import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/** BOL专用只读事实入口,应用服务不直接持有Mapper。 */
@Repository
public class DispatchBolRepository {
private final DispatchBolMapper mapper;
/** 装配同一次事务使用的来源及分配读取。 */
public DispatchBolRepository(DispatchBolMapper mapper){this.mapper=mapper;}
/** 返回完整明细身份、原调度箱数及逐来源打印资料。 */
public List<DispatchBolMapper.Cargo> cargo(long dispatchId){return mapper.findWholeDispatch(dispatchId);}
/** 仅正式FBA预约分配有外部预约号;客户仓内部DISPATCH批次不伪造约号。 */
public List<DispatchBolMapper.Allocation> allocations(long dispatchId){return mapper.findAppointmentAllocations(dispatchId);}
}
@@ -369,7 +369,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.SUBMIT_REVIEW,request.recordVersion(),request.idempotencyKey(),request);
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
requireEditing(row,request.recordVersion());
if(!Set.of(ReviewState.DRAFT.name(),ReviewState.DISPATCH_REJECTED.name(),ReviewState.LOADING_REJECTED.name()).contains(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对");
if(!canSubmitReview(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对");
verifyReviewResources(row);
if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核");
long round=row.getReviewRound()+1;String basis=basis(row);
@@ -739,6 +739,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(!Status.ACTIVE.name().equals(row.getStatus()) || !Stage.BEFORE_LOADING.name().equals(row.getStage()) || n(row.getPickedPackageCount())>0 || n(row.getShippedPackageCount())>0) throw conflict("仅装车前且未扫码的有效排车可以调整货物");
}
/** 提交资格和写入共用可重提状态,已进入两级待审的整车不得再次显示提交入口。 */
private static boolean canSubmitReview(String reviewState) {
return ReviewState.DRAFT.name().equals(reviewState) || ReviewState.DISPATCH_REJECTED.name().equals(reviewState)
|| ReviewState.LOADING_REJECTED.name().equals(reviewState);
}
/** 动作快照只反映实际当前资格,写入仍须再次加锁校验。 */
Map<Action,Gate> actions(Actor actor,DispatchAggregate row) {
var gates=new EnumMap<Action,Gate>(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus());
@@ -746,7 +752,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
for(var action:Action.values()) {
boolean state=switch(action) {
case CREATE -> actor.mode()==ActorMode.PLATFORM;
case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL,SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0;
case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0;
case SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0 && canSubmitReview(row.getReviewState());
case DISPATCH_REVIEW -> active && review==ReviewState.DISPATCH_PENDING;
case LOADING_REVIEW -> active && review==ReviewState.LOADING_PENDING;
case RETURN_TO_REVIEW -> active && stage==Stage.LOADING && n(row.getPickedPackageCount())==0;
@@ -32,6 +32,7 @@ public class DispatchBolPrintService {
@Resource private DomesticWarehouseDirectoryClient warehouses;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
/** 按本车一致快照组装全部BOL,一次调用打印服务并保持一个PDF下载。 */
public GeneratedPdf generate(String dispatchNo, String requestId) {
DispatchBolData detail = dispatches.read(dispatchNo);
if (detail.bolLines() == null || detail.bolLines().isEmpty()) {
@@ -54,6 +55,7 @@ public class DispatchBolPrintService {
}
}
/** 将同目的地同预约号的跨客户货物组装为独立分页BOL,无预约号时ISA保持空白。 */
private List<PrintPdfClient.Document> documents(DispatchBolData detail) {
String shipFromAddress = warehouseAddress(detail.overseasWarehouseId());
Map<String, List<DispatchBolData.Line>> groups = new LinkedHashMap<>();
@@ -72,7 +74,7 @@ public class DispatchBolPrintService {
.map(line -> new PrintPdfClient.CargoRow(text(line.containerNo()), text(line.palletNo()), text(line.fbaWarehouseCode()),
text(line.warehouseNo()), text(line.fbaShipmentId()), text(line.poReferenceNo()), line.packageCount())).toList();
documents.add(new PrintPdfClient.Document(text(detail.overseasWarehouseName()), shipFromAddress, text(detail.carrierName()),
text(detail.appointmentFor(lines.get(0))), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—",
lines.get(0).appointmentNo() == null ? "" : lines.get(0).appointmentNo().strip(), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—",
text(lines.get(0).fbaWarehouseCode()), destination, "—", text(detail.dispatchNo()), unique(lines, true), unique(lines, false),
total, page + 1, pages, rows));
offset = end;
@@ -1,31 +1,92 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.model.DispatchBolData;
import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.baiyue.lastleg.dispatch.repository.DispatchBolRepository;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.time.*;
import java.util.*;
import java.util.stream.Collectors;
/** 先在本地一致事务读全车打印事实,事务结束后才调用远程打印。 */
/** 同一只读事务内核实本车逐来源数量,再按真实预约份额拆行;事务结束后才调用远程打印。 */
@Service
public class DispatchBolReadService {
private final DispatchAggregateRepository roots;private final DispatchBolMapper lines;private final ObjectMapper json;
public DispatchBolReadService(DispatchAggregateRepository roots,DispatchBolMapper lines,ObjectMapper json){this.roots=roots;this.lines=lines;this.json=json;}
private final DispatchAggregateRepository roots;private final DispatchBolRepository facts;private final ObjectMapper json;
private record Source(Long itemId,String customerId,Long orderId,String destinationKey) { }
private record AllocationKey(Long itemId,String batchKey,String customerId,String sourceKey,String destinationKey) { }
/** 打印读取只经Repository,不写预约、调度或库存事实。 */
public DispatchBolReadService(DispatchAggregateRepository roots,DispatchBolRepository facts,ObjectMapper json){this.roots=roots;this.facts=facts;this.json=json;}
/** 保留原调度总量,未约部分以空约号打印,不按部分拣货进度缩量。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchBolData read(String number) {
var row=roots.find(number);if(row==null)throw new BizException(ResultCode.NOT_FOUND,"排车单不存在");
Resources resources;try{resources=json.readValue(row.getResourcesJson(),Resources.class);}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("本车资源快照损坏",e);}
var cargo=lines.findWholeDispatch(row.getId());
if(cargo.stream().anyMatch(l->l.customerId()==null || l.destinationKey()==null || l.packageCount()<=0))throw new BizException(ResultCode.VALIDATION_FAILED,"BOL货物客户、目的地或箱数尚未核实");
var confirmations=new LinkedHashMap<String,LinkedHashSet<String>>();
for(var batch:roots.batches(row.getId()))if(batch.confirmationNo()!=null && !batch.confirmationNo().isBlank())confirmations.computeIfAbsent(batch.customerId()+"\u0000"+batch.destinationKey(),k->new LinkedHashSet<>()).add(batch.confirmationNo());
var numbers=confirmations.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey,e->String.join(", ",e.getValue())));
var cargo=new LinkedHashMap<Source,DispatchBolData.Line>();var totals=new HashMap<Long,Integer>();var pallets=new HashMap<Long,Long>();
for(var raw:facts.cargo(row.getId())) {
int packages=nonnegative(raw.packageCount());var line=line(raw,null,packages);
if(raw.dispatchItemId()==null || blank(raw.customerId()) || line.destinationKey()==null
|| raw.originalPlan() && raw.orderId()==null)throw invalid();
int total=positive(raw.itemPackageCount());var prior=totals.putIfAbsent(raw.dispatchItemId(),total);
if(prior!=null && prior!=total)throw invalid();
if(pallets.containsKey(raw.dispatchItemId()) && !Objects.equals(pallets.get(raw.dispatchItemId()),raw.planPalletId()))throw invalid();
pallets.put(raw.dispatchItemId(),raw.planPalletId());
if(packages==0)continue;
var key=new Source(raw.dispatchItemId(),raw.customerId(),raw.orderId(),line.destinationKey());
cargo.merge(key,line,DispatchBolReadService::merge);
}
var sourceTotals=new HashMap<Long,Integer>();
cargo.forEach((key,line)->sourceTotals.merge(key.itemId(),line.packageCount(),Math::addExact));
if(!sourceTotals.equals(totals))throw invalid();
var remaining=new LinkedHashMap<Source,Integer>();cargo.forEach((key,line)->remaining.put(key,line.packageCount()));
var seen=new HashSet<AllocationKey>();var result=new ArrayList<DispatchBolData.Line>();
for(var allocation:facts.allocations(row.getId())) {
if(allocation.appointmentId()==null || allocation.orderId()==null || blank(allocation.sourceKey())
|| !Objects.equals(pallets.get(allocation.dispatchItemId()),allocation.planPalletId()))throw invalid();
var key=new Source(allocation.dispatchItemId(),allocation.customerId(),allocation.orderId(),allocation.destinationKey());
var source=cargo.get(key);int count=positive(allocation.packageCount());
if(source==null || !seen.add(new AllocationKey(allocation.dispatchItemId(),allocation.batchKey(),allocation.customerId(),allocation.sourceKey(),allocation.destinationKey()))
|| count>remaining.get(key))throw invalid();
remaining.put(key,remaining.get(key)-count);result.add(copy(source,count,allocation.confirmationNo()));
}
// 每个来源只消费实际分配份额,余额有明确实物依据,保持空ISA而非重复整托。
remaining.forEach((key,count)->{if(count>0)result.add(copy(cargo.get(key),count,null));});
var printedTotals=new HashMap<Long,Integer>();result.forEach(line->printedTotals.merge(line.dispatchItemId(),line.packageCount(),Math::addExact));
if(!printedTotals.equals(totals))throw invalid();
return new DispatchBolData(row.getDispatchNo(),row.getOverseasWarehouseId(),row.getOverseasWarehouseName(),resources.vehicle()==null?null:resources.vehicle().carrierName(),resources.vehicle()==null?null:resources.vehicle().plate(),
row.getEstimatedArrivalAt()==null?null:LocalDateTime.ofInstant(row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC),ZoneId.systemDefault()),List.copyOf(cargo),Map.copyOf(numbers));
row.getEstimatedArrivalAt()==null?null:LocalDateTime.ofInstant(row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC),ZoneId.systemDefault()),List.copyOf(result));
}
/** 将数据库原始来源行转换为带真实身份的内部打印行。 */
private static DispatchBolData.Line line(DispatchBolMapper.Cargo c,String appointment,int packages) {
return new DispatchBolData.Line(c.dispatchItemId(),c.orderId(),c.customerId(),c.destinationType(),c.deliveryAddress(),c.postalCode(),c.containerNo(),c.palletNo(),
c.fbaWarehouseCode(),c.destinationWarehouseName(),c.warehouseNo(),c.fbaShipmentId(),c.poReferenceNo(),packages,appointment);
}
/** 拆份只改变份额和预约号,真实客户、源订单和排车明细身份不变。 */
private static DispatchBolData.Line copy(DispatchBolData.Line c,int packages,String appointment) {
return new DispatchBolData.Line(c.dispatchItemId(),c.orderId(),c.customerId(),c.destinationType(),c.deliveryAddress(),c.postalCode(),c.containerNo(),c.palletNo(),
c.fbaWarehouseCode(),c.destinationWarehouseName(),c.warehouseNo(),c.fbaShipmentId(),c.poReferenceNo(),packages,appointment);
}
/** 同订单多来源行先守恒合并,再让每条预约分配消费一次;展示字符串只去重合并。 */
private static DispatchBolData.Line merge(DispatchBolData.Line a,DispatchBolData.Line b) {
return new DispatchBolData.Line(a.dispatchItemId(),a.orderId(),a.customerId(),a.destinationType(),a.deliveryAddress(),a.postalCode(),join(a.containerNo(),b.containerNo()),a.palletNo(),
a.fbaWarehouseCode(),a.destinationWarehouseName(),join(a.warehouseNo(),b.warehouseNo()),join(a.fbaShipmentId(),b.fbaShipmentId()),join(a.poReferenceNo(),b.poReferenceNo()),Math.addExact(a.packageCount(),b.packageCount()),null);
}
/** 仅打印展示字段去重,不用逗号字符串表示预约身份。 */
private static String join(String a,String b) {
var values=new LinkedHashSet<String>();for(var text:Arrays.asList(a,b))if(text!=null)for(var part:text.split(","))if(!part.isBlank())values.add(part.strip());
return values.isEmpty()?null:String.join(", ",values);
}
/** 数量未知或非正不得伪造打印份额。 */
private static int positive(Integer value){if(value==null || value<=0)throw invalid();return value;}
/** 已核实为零的源行没有待打印货物,但不以零替代未知数量。 */
private static int nonnegative(Integer value){if(value==null || value<0)throw invalid();return value;}
/** 来源身份必须来自真实持久事实。 */
private static boolean blank(String value){return value==null || value.isBlank();}
/** 不完整、跨客户或超额分配不能生成看似守恒的BOL。 */
private static BizException invalid(){return new BizException(ResultCode.VALIDATION_FAILED,"BOL货物来源、预约归属或分配箱数尚未核实");}
}