This commit is contained in:
2026-10-21 07:53:31 +08:00
parent af5cf1e3bd
commit f00d0e16cf
147 changed files with 1696 additions and 872 deletions
@@ -132,6 +132,7 @@ public class DeliveryTransferService {
@Resource private com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository cancellations;
@Resource private com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository outboundQuantities;
/** 创建一件代发出库任务,使用独立的 DFCK 日流水标识。 */
@Transactional
public DeliveryResponses.PdaTask createOneClick(String customerId, String operatorId, DeliveryRequests.OneClickOutbound request) {
String requestHash = oneClickRequestHash(request);
@@ -145,7 +146,7 @@ public class DeliveryTransferService {
String selectedWarehouseId = required(firstPosition.getOverseasWarehouseId(), "库存缺少所属海外仓");
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
outbound.setOutboundNo(orderNoGenerator.next("ONE_CLICK_DROPSHIP", selectedWarehouseId, outbound.getId()));
outbound.setOutboundNo(orderNoGenerator.next("ONE_CLICK_DROPSHIP"));
outbound.setOutboundType("ONE_CLICK_DROPSHIP"); outbound.setStatus("WAIT_PICK"); outbound.setRecipientInfo(request.recipient().trim());
outbound.setPackageCount(request.items().stream().mapToInt(i -> i.packages().intValue()).sum()); outbound.setPalletCount(request.items().size());
outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
@@ -227,6 +228,7 @@ public class DeliveryTransferService {
value(c.getTotalQuantity()), c.getStatus())).toList();
}
/** 校验退货库存后建立出库和派送单,各自分配业务日流水。 */
@Transactional
public DeliveryResponses.PdaTask createReturnOutbound(String customerId, String operatorId,
DeliveryRequests.ReturnOutbound request) {
@@ -279,7 +281,7 @@ public class DeliveryTransferService {
}
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
outbound.setOutboundNo(orderNoGenerator.next("RETURN_OUTBOUND", warehouseId, outbound.getId()));
outbound.setOutboundNo(orderNoGenerator.next("RETURN_OUTBOUND"));
outbound.setOutboundType("RETURN_OUTBOUND"); outbound.setStatus("WAIT_PICK");
outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(locked.size()); outbound.setPalletCount("PALLET".equals(request.unitType()) ? 1 : 0);
outbound.setOverseasWarehouseId(warehouseId); outbound.setIdempotencyKey(request.idempotencyKey().trim());
@@ -296,7 +298,7 @@ public class DeliveryTransferService {
carton.setStatus("OUTBOUND_PENDING"); carton.setUpdatedAt(now); returnCartons.updateById(carton);
}
DeliveryOrder delivery = new DeliveryOrder(); delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId);
delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", warehouseId, delivery.getId()));
delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY"));
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
delivery.setOverseasWarehouseId(warehouseId); delivery.setDeliveryGroupKey("RETURN:" + request.returnNo().trim());
delivery.setStatus("WAIT_DELIVERY"); delivery.setDeliveryAddress(request.recipient().trim());
@@ -358,6 +360,7 @@ public class DeliveryTransferService {
return result;
}
/** 为仓储转运建立普通出库和派送单,保留原库存关联。 */
@Transactional
public DeliveryResponses.OutboundDetail createWarehouseTransferOutbound(String customerId, String operatorId,
DeliveryRequests.WarehouseTransferOutbound request) {
@@ -397,7 +400,7 @@ public class DeliveryTransferService {
LocalDateTime now = LocalDateTime.now();
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", transfer.getOperatingWarehouseId(), outbound.getId()));
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND"));
outbound.setOverseasWarehouseId(transfer.getOperatingWarehouseId());
outbound.setOutboundType("WAREHOUSE_TRANSFER_OUTBOUND"); outbound.setStatus("WAIT_PICK");
outbound.setRecipientInfo("PRIVATE".equals(destinationType)
@@ -434,7 +437,7 @@ public class DeliveryTransferService {
LastlegOrder tailOrder = tailOrders.selectById(transfer.getLastlegOrderId());
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId);
delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", transfer.getOperatingWarehouseId(), delivery.getId()));
delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY"));
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
delivery.setOverseasWarehouseId(transfer.getOperatingWarehouseId());
delivery.setCustomerName(tailOrder == null ? null : tailOrder.getCustomerName());
@@ -130,7 +130,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) {
var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList();
var order=new OutboundOrder();order.setId(IdWorker.getId());order.setCustomerId(customer);
order.setOutboundNo(numbers.next("OUTBOUND",context.warehouseId(),order.getId()));
order.setOutboundNo(numbers.next("OUTBOUND"));
order.setDispatchOrderId(context.dispatchId());order.setDispatchNo(context.dispatchNo());order.setOverseasWarehouseId(context.warehouseId());
order.setOutboundType("DISPATCH_DELIVERY");order.setStatus("WAIT_OUTBOUND");order.setLxCodes("");
order.setPackageCount(Math.toIntExact(cargo.stream().mapToLong(CargoLine::packages).sum()));
@@ -276,7 +276,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
/** 客户、目的地和车辆司机仅来自权威事实,请求不能覆盖本趟资源。 */
private DeliveryOrder delivery(Context context,Group group,ArrangeGroup input,OutboundOrder outbound,String operator,String key,Instant now) {
var d=new DeliveryOrder();d.setId(IdWorker.getId());d.setCustomerId(group.customerId());d.setCustomerName(group.customerName());
d.setDeliveryNo(numbers.next("DELIVERY",context.warehouseId(),d.getId()));d.setOutboundOrderId(outbound.getId());d.setOutboundNo(outbound.getOutboundNo());
d.setDeliveryNo(numbers.next("DELIVERY"));d.setOutboundOrderId(outbound.getId());d.setOutboundNo(outbound.getOutboundNo());
d.setDispatchOrderId(context.dispatchId());d.setDispatchNo(context.dispatchNo());d.setOverseasWarehouseId(context.warehouseId());d.setOverseasWarehouseName(context.warehouseName());
var destination=group.destination();d.setDeliveryGroupKey(grouping.documentGroupKey(destination));d.setDestinationType(destination.type());
d.setDestinationWarehouseId(destination.warehouseId());d.setDestinationWarehouseCode(destination.warehouseCode());d.setDestinationWarehouseName(destination.warehouseName());
@@ -48,7 +48,7 @@ public class CargoDiscardOutboundService {
LocalDateTime at = LocalDateTime.now();
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId());
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND"));
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE);
outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo());
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false));
@@ -222,7 +222,7 @@ public class CargoDiscardService {
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
CargoDiscardApplication application = new CargoDiscardApplication();
application.setId(IdWorker.getId()); application.setCustomerId(customerId);
application.setDiscardNo(orderNoGenerator.next("DISCARD", warehouseId, application.getId()));
application.setDiscardNo(orderNoGenerator.next("DISCARD"));
application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName);
application.setInboundCodes(java.util.stream.Stream.concat(
java.util.stream.Stream.concat(orderCache.values().stream().map(LastlegOrder::getInboundCode),
@@ -301,8 +301,9 @@ public interface DispatchCandidateMapper {
"c.pallet_code AS palletNo,COUNT(DISTINCT c.id) AS packageCount,'RELABEL' AS sourceStage,1 AS returnCartonSource," +
"GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') AS relabelCartonMarks," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS fbaWarehouseCode," +
"COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(x.weight) THEN SUM(x.weight) END FROM (SELECT DISTINCT c2.id,c2.weight FROM ll_return_inventory_carton c2 JOIN ll_relabel_item ri2 ON ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id JOIN ll_relabel_application a2 ON a2.id=ri2.application_id AND a2.customer_id=ri2.customer_id WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND a2.id=a.id) x),(SELECT rr.weight FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS grossWeightKg," +
"COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(x.volume) THEN SUM(x.volume) END FROM (SELECT DISTINCT c2.id,(c2.length*c2.width*c2.height)/1000000 AS volume FROM ll_return_inventory_carton c2 JOIN ll_relabel_item ri2 ON ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id JOIN ll_relabel_application a2 ON a2.id=ri2.application_id AND a2.customer_id=ri2.customer_id WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND a2.id=a.id) x),(SELECT rr.length*rr.width*rr.height/1000000 FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS volumeCbm," +
// 从唯一实箱汇总并用EXISTS核对申请,避免一箱多明细重复计量和跨层相关派生表。
"COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(c2.weight) THEN SUM(c2.weight) END FROM ll_return_inventory_carton c2 WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND EXISTS (SELECT 1 FROM ll_relabel_item ri2 WHERE ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id AND ri2.application_id=a.id)),(SELECT rr.weight FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS grossWeightKg," +
"COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(c2.length*c2.width*c2.height) THEN SUM(c2.length*c2.width*c2.height/1000000) END FROM ll_return_inventory_carton c2 WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND EXISTS (SELECT 1 FROM ll_relabel_item ri2 WHERE ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id AND ri2.application_id=a.id)),(SELECT rr.length*rr.width*rr.height/1000000 FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS volumeCbm," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseCode " +
"FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " +
"JOIN ll_return_inventory_carton c ON c.id=ri.return_carton_id AND c.customer_id=a.customer_id " +
@@ -19,6 +19,7 @@ public interface DispatchCandidatePageMapper {
/** 复用既有资格SQL,只统一各来源投影的列序,不复制资格或客户隔离规则。 */
final class Sql {
private static final String FIELDS="planId,planPalletId,warehousePalletId,orderId,customerId,customerName,planNo,orderType,volumeWeightKg,grossWeightKg,containerNo,appointmentAt,expectedLoadingAt,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,orderNo,orderRefs,inboundCode,palletNo,destinationWarehouseId,destinationWarehouseCode,destinationWarehouseName,fbaWarehouseCode,packageCount,volumeCbm,sourceStage,cartonMarks,relabelCartonMarks,destinationType,deliveryAddress,postalCode,contactName,contactPhone,returnReceiptId,returnReceiptNo,returnCartonSource";
private static final Set<String> TEXT_FIELDS=Set.of(("customerId,customerName,planNo,orderType,containerNo,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,orderNo,orderRefs,inboundCode,palletNo,destinationWarehouseId,destinationWarehouseCode,destinationWarehouseName,fbaWarehouseCode,sourceStage,cartonMarks,relabelCartonMarks,destinationType,deliveryAddress,postalCode,contactName,contactPhone,returnReceiptNo").split(","));
private static final Set<String> PLAN_MISSING=Set.of("returnReceiptId","returnReceiptNo","returnCartonSource");
private static final Set<String> RETURN_FIELDS=Set.of(("returnReceiptId,returnReceiptNo,orderId,customerId,customerName,orderType,orderNo,orderRefs,inboundCode,warehousePalletId,palletNo,overseasWarehouseId,overseasWarehouseName,overseasWarehouseCode,destinationType,destinationWarehouseCode,fbaWarehouseCode,destinationWarehouseName,deliveryAddress,postalCode,contactName,contactPhone,sourceStage,packageCount").split(","));
private static final Set<String> NATIVE_FIELDS=Set.of(("customerId,orderId,overseasWarehouseId,overseasWarehouseName,orderType,orderNo,orderRefs,inboundCode,palletNo,packageCount,sourceStage,returnCartonSource,relabelCartonMarks,fbaWarehouseCode,grossWeightKg,volumeCbm,destinationWarehouseCode").split(","));
@@ -27,32 +28,37 @@ public interface DispatchCandidatePageMapper {
+"<bind name='fbaWarehouseCode' value='query.destinationWarehouseCode'/><bind name='fba' value='query.destinationWarehouseCode'/>"
+"<bind name='sourceStage' value='query.sourceStage'/><bind name='draftToken' value='query.draftToken'/>";
/** 来源字段缺省仅表示该来源没有此字段,不伪造业务编号或实托身份。 */
private static String project(String body,Set<String> fields) {
return "SELECT "+Arrays.stream(FIELDS.split(",")).map(field->fields.contains(field)?"s."+field:
("returnCartonSource".equals(field)?"0":"NULL")+" AS "+field).collect(Collectors.joining(","))
private static String project(String body,Set<String> fields,boolean count) {
return "SELECT "+Arrays.stream((count?"overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,fbaWarehouseCode":FIELDS).split(",")).map(field->column(field,fields.contains(field)?"s."+field:
("returnCartonSource".equals(field)?"0":"NULL"))).collect(Collectors.joining(","))
+" FROM ("+body+") s";
}
/** 在UNION前统一文本列,避免表字段、CAST及聚合结果采用不同collation而报1271;数值与时间保持原类型。 */
private static String column(String field,String expression) {
return (TEXT_FIELDS.contains(field)?"CONVERT("+expression+" USING utf8mb4) COLLATE utf8mb4_unicode_ci":expression)+" AS "+field;
}
/** 不同来源用同一组筛选和稳定行标识组合,排序前排除未选来源类型。 */
private static String rows() {
private static String rows(boolean count) {
var planFields=new HashSet<>(Arrays.asList(FIELDS.split(",")));planFields.removeAll(PLAN_MISSING);
return "<choose><when test=\"sourceStage != 'RELABEL'\">"+project(DispatchCandidateMapper.AVAILABLE_PLAN_BODY,planFields)
+"</when><otherwise>SELECT "+Arrays.stream(FIELDS.split(",")).map(f->"NULL AS "+f).collect(Collectors.joining(","))+" WHERE 1=0</otherwise></choose>"
var fields=count?"overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,fbaWarehouseCode":FIELDS;
return "<choose><when test=\"sourceStage != 'RELABEL'\">"+project(DispatchCandidateMapper.AVAILABLE_PLAN_BODY,planFields,count)
+"</when><otherwise>SELECT "+Arrays.stream(fields.split(",")).map(f->column(f,"NULL")).collect(Collectors.joining(","))+" WHERE 1=0</otherwise></choose>"
+"<if test='planIds == null or planIds.isEmpty()'>"
+"<if test=\"sourceStage == null or sourceStage == 'RECEIVED'\"> UNION ALL "+project(ReturnDispatchCandidateMapper.AVAILABLE_BODY,RETURN_FIELDS)+"</if>"
+"<if test=\"sourceStage == null or sourceStage == 'RECEIVED'\"> UNION ALL "+project(ReturnDispatchCandidateMapper.AVAILABLE_BODY,RETURN_FIELDS,count)+"</if>"
+"<if test=\"sourceStage == null or sourceStage == 'RECEIVED' or sourceStage == 'RELABEL'\"> UNION ALL "
+project(DispatchCandidateMapper.RELABEL_BODY,planFields)+" UNION ALL "+project(DispatchCandidateMapper.NATIVE_RELABEL_BODY,NATIVE_FIELDS)+"</if></if>";
+project(DispatchCandidateMapper.RELABEL_BODY,planFields,count)+" UNION ALL "+project(DispatchCandidateMapper.NATIVE_RELABEL_BODY,NATIVE_FIELDS,count)+"</if></if>";
}
/** 仓库页签也是服务端筛选,分页后不能再用本地筛选伪装完整结果。 */
private static String filtered() {
return " FROM ("+rows()+") candidates WHERE overseasWarehouseId=#{query.warehouseId}"
private static String filtered(boolean count) {
return " FROM ("+rows(count)+") candidates WHERE overseasWarehouseId=#{query.warehouseId}"
+"<if test='query.destinationWarehouseCode != null'> AND fbaWarehouseCode=#{query.destinationWarehouseCode}</if>"
+"<if test=\"query.warehouseTab == 'LAX'\"> AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'lax|洛杉矶|los angeles'</if>"
+"<if test=\"query.warehouseTab == 'NYC'\"> AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'nyc|jfk|(^|[^a-z])ny([^a-z]|$)|纽约|new[ -]?york'</if>";
}
/** 同一事务内统计分页全集。 */
public static String count(){return "<script>"+BINDINGS+"SELECT COUNT(*)"+filtered()+"</script>";}
public static String count(){return "<script>"+BINDINGS+"SELECT COUNT(*)"+filtered(true)+"</script>";}
/** LIMIT发生在任何逐组Java装配之前,最后补来源身份列以保证同名来源不乱序。 */
public static String page(){return "<script>"+BINDINGS+"SELECT *"+filtered()
public static String page(){return "<script>"+BINDINGS+"SELECT *"+filtered(false)
+" ORDER BY customerId,orderNo,palletNo,sourceStage,planPalletId,returnReceiptId,orderRefs,returnCartonSource"
+" LIMIT #{query.size} OFFSET #{offset}</script>";}
}
@@ -524,7 +524,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var identity=identity(actor,number,Action.LOADING_COMPLETE,request.recordVersion(),request.idempotencyKey(),request);
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
if(row.getRecordVersion()!=request.recordVersion().longValue() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()) || !ReviewState.APPROVED.name().equals(row.getReviewState()))throw conflict("当前版本或审核阶段不能确认装车");
lockExecutionSources(row,repository.cargo(row.getId()));
var items=repository.cargo(row.getId());
// 未实收/未首拣的PLAN尚未物化实托,先按未核平拒绝,不能将空实托集合传给执行锁。
if(items.isEmpty() || items.stream().anyMatch(i->i.getWarehousePalletId()==null))throw conflict("逐项真实拣货尚未核平");
lockExecutionSources(row,items);
var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平");
if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对");
if(request.measurements()==null || request.measurements().size()!=preview.pallets().size())throw bad("请确认本车全部实托测量");
@@ -30,7 +30,7 @@ public class DispatchPdaQueryService {
require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号");
var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return assemble(actor,rows).get(0);
}
/** 固定批量查询数量,不因车数、客户数或托盘数执行逐行详情查询。 */
/** 已绑定实托批量读取;尚未绑定的原PLAN仅在本次只读快照内核对自然实收。 */
private List<DispatchViews.PdaTask> assemble(Actor actor,List<DispatchAggregate> roots) {
if(roots.isEmpty())return List.of();if(roots.size()>200)throw new BizException(ResultCode.BAD_REQUEST,"作业超过完整读取上限,请按阶段筛选或扫描具体排车单号");
var ids=roots.stream().map(DispatchAggregate::getId).toList();var cargo=repository.items(ids).stream().collect(Collectors.groupingBy(DispatchItem::getDispatchOrderId));
@@ -100,6 +100,7 @@ public class DispatchPdaQueryService {
if(rows.size()!=1)return List.of();
var actual=rows.get(0);
if(!"RECEIVED".equals(actual.sourceStage()) || actual.warehousePalletId()==null
|| !Objects.equals(actual.planPalletId(),item.getPlanPalletId())
|| !Objects.equals(actual.customerId(),item.getCustomerId())
|| !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId())
|| !Objects.equals(actual.packageCount(),item.getPackageCount())
@@ -112,7 +113,7 @@ public class DispatchPdaQueryService {
// 同托跨客户、换标及整申请来源必须全部已在原车;不能把新合托货物暴露为原计划可拣库存。
var group=sourceRules.selectionGroup(actor.boundWarehouseId(),new DispatchCommands.CargoSelection(
SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null));
if(!group.complete() || group.sources().isEmpty())return List.of();
if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return List.of();
for(var member:group.sources()) {
var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null);
if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId())
@@ -418,6 +418,7 @@ public class ExceptionOrderService {
tail == null ? inboundOrder.getPackageCount() : tail.getPackageCount(), List.of());
}
/** 保存异常单及来源关系,异常编号使用 EX 日流水。 */
private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery,
String type, String reason, Integer packages, String sku,
String direction, String logisticsNo, String sourceType,
@@ -428,7 +429,7 @@ public class ExceptionOrderService {
LocalDateTime now = LocalDateTime.now();
ExceptionOrder value = new ExceptionOrder();
value.setId(IdWorker.getId()); value.setCustomerId(relation.customerId());
value.setExceptionNo(orderNoGenerator.next("EXCEPTION", relation.overseasWarehouseId(), value.getId()));
value.setExceptionNo(orderNoGenerator.next("EXCEPTION"));
value.setExceptionType(type); value.setStatus("PENDING"); value.setReason(trim(reason));
value.setLastMileOrderId(relation.lastMileOrderId()); value.setLastMileOrderNo(relation.lastMileOrderNo());
value.setInboundOrderId(relation.inboundOrderId()); value.setInboundCode(relation.inboundCode());
@@ -57,7 +57,7 @@ public class ReturnCartonReturnService {
var pallet=pallets.resolve(input.targetPalletNo());
var location=warehouse.resolveLocation(Long.parseLong(order.getOverseasWarehouseId()),input.targetLocationCode());
if(pallet==null||pallet.palletId()!=input.targetPalletId()||!String.valueOf(pallet.warehouseId()).equals(order.getOverseasWarehouseId())||!Set.of("IDLE","OCCUPIED").contains(pallet.status())||location==null||location.locationId()!=input.targetLocationId()||!Objects.equals(pallet.locationId(),location.locationId())||!Set.of("ACTIVE","IDLE","OCCUPIED").contains(location.status())||"VIRTUAL".equalsIgnoreCase(location.locationType()))throw conflict("目标托盘、库位或海外仓不匹配,不能确认原箱回仓");
LocalDateTime now=LocalDateTime.now();String receiptNo=numbers.next("RETURN_RECEIPT",order.getOverseasWarehouseId(),IdWorker.getId());boolean first=true;
LocalDateTime now=LocalDateTime.now();String receiptNo=numbers.next("RETURN_RECEIPT");boolean first=true;
for(var movement:shipped) {
repository.restore(movement);
var receipt=new ReturnCartonStockReceipt();receipt.setId(IdWorker.getId());receipt.setReceiptNo(receiptNo);receipt.setCustomerId(order.getCustomerId());
@@ -22,13 +22,13 @@ import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*;
public class FleetPoolOrderBridge {
@jakarta.annotation.Resource private FleetLooseboardDemandRepository demands;
private final FleetPoolOrderRepository repository;
private final FleetPoolOrderNumberGenerator numbers;
private final com.baiyue.lastleg.service.LastlegDocumentNumberGenerator numbers;
private final ObjectMapper json;
private final FleetPoolDeliveryEvidence deliveryEvidence;
private final com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources;
/** 注入同库商业委托仓储及履约协作服务。 */
public FleetPoolOrderBridge(FleetPoolOrderRepository repository,
FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence,
com.baiyue.lastleg.service.LastlegDocumentNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence,
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources) {
this.repository=repository;
this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
@@ -53,7 +53,7 @@ public class FleetPoolOrderBridge {
var now=LocalDateTime.now();var row=new FleetPoolOrderLink();
row.setId(IdWorker.getId());row.setBookingId(in.bookingId());row.setBookingNo(in.bookingNo());
row.setCustomerId(in.customerId());row.setBusinessType(in.businessType());row.setBusinessOrderId(source==null?IdWorker.getId():source.getId());
row.setBusinessOrderNo(source==null?numbers.next("US_TRUCK".equals(in.businessType())?"TRUCK":"LOOSEBOARD",in.destinationLabel(),row.getBusinessOrderId()):source.getOrderNo());
row.setBusinessOrderNo(source==null?numbers.next("US_TRUCK".equals(in.businessType())?"TR":"LB"):source.getOrderNo());
row.setWarehouseId(in.warehouseId());row.setDestinationKey(in.destinationKey());
row.setDeliveryMode(in.deliveryMode());row.setDeliveryInstructions(in.deliveryInstructions());
row.setFulfillmentStage("WAITING_INBOUND");row.setStageOccurredAt(now);row.setRecordVersion(0L);
@@ -1,23 +0,0 @@
package com.baiyue.lastleg.fleet.business;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.regex.Pattern;
/** Commercial pool orders own LB/TR numbering; no tail-leg order sequence is consumed. */
@Component
public class FleetPoolOrderNumberGenerator {
private static final Pattern LOCATION=Pattern.compile("LAX|ONT|NYC|JFK|LGB|EWR|SFO|SEA|DFW|ORD|ATL|MIA|PHX|LAS|YYZ|YVR|AMS|LHR|FRA|CDG|HKG|NRT|SYD");
public String next(String businessType,String destination,long uniqueId){
String prefix=switch(businessType){case "TRUCK"->"TR";case "LOOSEBOARD"->"LB";default->throw new IllegalArgumentException("Unsupported pool business type");};
return prefix+"-"+location(destination)+"-"+LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)
+"-"+String.format(Locale.ROOT,"%06d",Math.floorMod(uniqueId,1_000_000));
}
private static String location(String value){
String code=value==null?"":value.trim().toUpperCase(Locale.ROOT);var match=LOCATION.matcher(code);
if(Pattern.matches(".*("+LOCATION.pattern()+").*",code)&&match.find())return match.group();
return code.matches("[A-Z0-9]{2,8}")?code:"WH";
}
}
@@ -22,6 +22,8 @@ import java.util.*;
@Service
public class VehicleCareService {
@jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers;
private final VehicleCareMapper records;
private final VehicleCareHistoryMapper histories;
private final VehicleRepository vehicles;
@@ -41,6 +43,7 @@ public class VehicleCareService {
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
public VehicleCareModels.Response detail(long vehicleId, String number) { return response(required(vehicleId, number, false)); }
/** 建立车辆运维记录,首次建单分配 VR 编号。 */
@Transactional
public VehicleCareModels.Response create(long vehicleId, String operator, VehicleCareModels.Create request) {
// Serialize creation per vehicle; no empty-range locks or duplicate creation after a lost response.
@@ -53,7 +56,7 @@ public class VehicleCareService {
}
validate(request.details());
var row = new VehicleCareEntity(); row.setId(IdWorker.getId());
row.setRecordNo("VR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT));
row.setRecordNo(documentNumbers.next("VR"));
row.setVehicleId(vehicleId); row.setVehiclePlate(vehicle.plateNumber()); row.setRecordType(request.details().recordType());
row.setOccurredAt(utc(request.details().occurredAt())); row.setDetailsJson(write(request.details())); row.setFilesJson("[]");
row.setIdempotencyKey(request.idempotencyKey()); row.setRequestHash(fingerprint); row.setRecordVersion(0L);
@@ -26,6 +26,8 @@ import java.util.Objects;
@Service
public class FleetBookingService {
@jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers;
private final FleetTripMapper trips;
private final FleetBookingMapper bookings;
private final FleetBookingReservations reservations;
@@ -56,13 +58,14 @@ public class FleetBookingService {
var context=quoteContext.resolve(trip(tripNo,false),query.customerId(),query.destinationKey(),query.currency(),query.priceBasis());
return quotes.candidates(new FleetQuoteCandidateModels.Query(context.customerId(),context.originCity(),context.destinationCode(),context.currency(),context.effectiveOn(),context.priceBasis(),query.page(),query.size()));
}
/** 在车次内登记预订,首次请求分配 FB 业务号。 */
@Transactional
public FleetBookingModels.Result create(String tripNo,String operator,FleetBookingModels.Create request){
var trip=trip(tripNo,true);String hash=hash(request);
var existing=bookings.created(trip.getId(),operator,request.idempotencyKey());
if(existing!=null){if(!hash.equals(existing.getRequestHash()))throw conflict("同一登记请求不能变更客户或货物内容");return result(trip,existing,true);}
requireTrip(trip,request.tripRecordVersion());var customer=customers.required(request.customerId());
var row=new FleetBookingEntity();row.setId(IdWorker.getId());row.setBookingNo("FB-"+row.getId());row.setTripId(trip.getId());row.setTripNo(tripNo);
var row=new FleetBookingEntity();row.setId(IdWorker.getId());row.setBookingNo(documentNumbers.next("FB"));row.setTripId(trip.getId());row.setTripNo(tripNo);
row.setCustomerId(customer.id());row.setCustomerCode(customer.code());row.setCustomerName(customer.name());row.setStatus("DRAFT");
apply(trip,row,request.cargo());applyQuote(trip,row,request.quoteRef());row.setIdempotencyKey(request.idempotencyKey());row.setRequestHash(hash);row.setRecordVersion(0L);
row.setCreatedBy(operator);row.setCreatedAt(now());row.setUpdatedBy(operator);row.setUpdatedAt(row.getCreatedAt());
@@ -25,6 +25,8 @@ import java.util.*;
@Service
public class FleetCapacityOfferService {
@jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers;
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService demands;
public static final String LIST="platform:fleet:capacity-offer:list", MANAGE="platform:fleet:capacity-offer:manage";
private final FleetCapacityOfferMapper offers;
@@ -131,6 +133,7 @@ public class FleetCapacityOfferService {
var row=offer(number,false);requireVisible(row);customers.required(query.customerId());
return bookingService.quoteCandidates(row.getTripNo(),query);
}
/** 发布车队运力并分配 FO 日流水,重放复用原发布记录。 */
@Transactional
public FleetCapacityOfferModels.OfferResult create(FleetCapacityOfferModels.Write request){
requireManage();var trip=trip(request.tripNo(),true);
@@ -139,7 +142,7 @@ public class FleetCapacityOfferService {
tripVersion(trip,request.tripRecordVersion());
if(offers.byTrip(trip.getId())!=null)throw conflict("该车次已有发布,请从既有发布修改");
validateCutoff(trip,request.cutoffAt());
var row=new FleetCapacityOfferEntity();row.setId(IdWorker.getId());row.setOfferNo("FO-"+row.getId());row.setTripId(trip.getId());row.setTripNo(trip.getTripNo());
var row=new FleetCapacityOfferEntity();row.setId(IdWorker.getId());row.setOfferNo(documentNumbers.next("FO"));row.setTripId(trip.getId());row.setTripNo(trip.getTripNo());
row.setStatus("DRAFT");row.setRecordVersion(0L);row.setCreatedBy(operator());row.setCreatedAt(now());apply(row,request);
if(offers.insert(row)!=1)throw conflict("发布创建失败");
var result=new FleetCapacityOfferModels.OfferResult(project(row,trip),false);saveCommand(trip,"CREATE",trip.getTripNo(),request.idempotencyKey(),request,result);return result;
@@ -27,6 +27,8 @@ import java.util.*;
@Service
public class FleetTripService {
@jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers;
@org.springframework.beans.factory.annotation.Autowired(required=false) private org.springframework.context.ApplicationEventPublisher transportEvents;
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workChanges;
@jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations;
@@ -114,7 +116,7 @@ public class FleetTripService {
var warehouse = warehouses.required(request.warehouseId(), "OVERSEAS");
var trip = new FleetTripEntity();
trip.setId(IdWorker.getId());
trip.setTripNo("TR-" + new java.math.BigInteger(UUID.randomUUID().toString().replace("-", ""), 16).toString(36).toUpperCase(Locale.ROOT));
trip.setTripNo(documentNumbers.next("CT"));
trip.setIdempotencyKey(request.idempotencyKey()); trip.setRequestHash(request.commandHash());
trip.setStatus("PLANNED"); trip.setTripPurpose("DELIVERY"); trip.setWarehouseId(request.warehouseId());
trip.setWarehouseCode(warehouse.code()); trip.setWarehouseName(warehouse.name());
@@ -390,7 +392,7 @@ public class FleetTripService {
var warehouse = warehouses.required(request.warehouseId(), "OVERSEAS");
var trip = new FleetTripEntity();
trip.setId(IdWorker.getId());
trip.setTripNo("TR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT));
trip.setTripNo(documentNumbers.next("CT"));
trip.setIdempotencyKey(request.idempotencyKey()); trip.setRequestHash(hash);
trip.setTripPurpose(purpose); trip.setStatus("PLANNED"); trip.setWarehouseId(Long.valueOf(warehouse.id()));
trip.setWarehouseCode(warehouse.code()); trip.setWarehouseName(warehouse.name());
@@ -179,15 +179,14 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw palletLookupFailure(result.getStatusCode()); })
.body(WarehousePalletResponse.class);
if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Warehouse pallet service returned no usable response");
requirePalletResponse(response);
return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().warehouseId(),
response.data().locationId(), response.data().status(), response.data().fbaWarehouseCode(),
response.data().unitPrice(), response.data().version());
}
private ResponseStatusException palletLookupFailure(HttpStatusCode status) {
return new ResponseStatusException(status.value() == 404 ? HttpStatus.NOT_FOUND
return new ResponseStatusException(status.value() == 409 ? HttpStatus.CONFLICT : status.value() == 404 ? HttpStatus.NOT_FOUND
: (status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE),
status.value() == 404 ? "Warehouse pallet was not found" : "Warehouse pallet lookup failed");
}
@@ -199,14 +198,23 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw palletLookupFailure(result.getStatusCode());
}).body(WarehousePalletResponse.class);
requirePalletResponse(response);
return response;
}
/** 托盘状态冲突是业务拒绝,不能把HTTP 200内的A0409误报为仓储服务不可用。 */
private void requirePalletResponse(WarehousePalletResponse response) {
if (response != null && "A0409".equals(response.code())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
response.message() == null || response.message().isBlank() ? "Warehouse pallet state conflicts with this operation" : response.message());
}
if (response != null && "A0404".equals(response.code())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse pallet was not found");
}
if (response == null || response.data() == null) {
if (response == null || !"200".equals(response.code()) || response.data() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Warehouse pallet service returned no usable response");
}
return response;
}
private WarehousePalletReference reference(WarehousePalletData pallet) {
@@ -292,7 +292,7 @@ public class ContainerInboundPlanConversionCoordinator {
? plans.existingPlanIdentity(order.customerId(), Long.parseLong(order.orderId())) : null;
long planId = existing == null ? IdWorker.getId() : existing.planId();
String planNo = existing == null
? numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId) : existing.planNo();
? numbers.next("INBOUND_PLAN") : existing.planNo();
if (existing != null && existing.palletCount() != targetPalletCount) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划托数与柜级预览已不一致");
}
@@ -23,6 +23,8 @@ import java.util.stream.Collectors;
@Service
public class InboundFbaAppointmentService {
@jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers;
@Resource private InboundFbaAppointmentMapper appointmentMapper;
@Resource private InboundFbaAppointmentItemMapper itemMapper;
@Resource private InboundFbaAppointmentQueryMapper queryMapper;
@@ -76,6 +78,7 @@ public class InboundFbaAppointmentService {
new AppointmentCoverageService.SourceRef(source.customerId(),source.sourceKey(),source.destinationKey())).toList(),
request.originWarehouseId(),request.fbaWarehouseCode(),request.details()));
}
/** 校验预约覆盖的来源单据,首次创建时分配 YY 预约号。 */
private FbaAppointmentResponse create(String operator,List<AppointmentCoverageService.SourceRef> refs,String expectedOrigin,String expectedDestination,FbaAppointmentDetails details) {
var resolved=resolveForWrite(refs);
var group=group(resolved);
@@ -83,7 +86,7 @@ public class InboundFbaAppointmentService {
if(!Objects.equals(group.originWarehouseId(),expectedOrigin) || !Objects.equals(group.destinationKey(),"FBA:"+expectedDestination)) throw conflict("接仓点或目的仓已变化,请重新核对预约货物");
var appointment=new InboundFbaAppointment();
appointment.setId(IdWorker.getId());
appointment.setAppointmentNo("AP-"+LocalDate.now(ZoneOffset.UTC).toString().replace("-","")+"-"+UUID.randomUUID().toString().replace("-","").substring(0,12).toUpperCase(Locale.ROOT));
appointment.setAppointmentNo(documentNumbers.next("YY"));
appointment.setOriginWarehouseId(group.originWarehouseId());
appointment.setFbaWarehouseCode(resolved.get(0).destinationWarehouseCode());
appointment.setSelectedCount(resolved.size());appointment.setVersion(0);
@@ -261,6 +264,7 @@ public class InboundFbaAppointmentService {
appointment.setAppointmentStartUtc(null);appointment.setAppointmentEndUtc(null);
}
/** 重新分组预约来源,新建预约分配 YY 编号并保留覆盖约束。 */
@Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public RepartitionFbaAppointmentsResponse repartition(String operator,RepartitionFbaAppointmentsRequest request) {
validateRepartition(request);
@@ -330,7 +334,7 @@ public class InboundFbaAppointmentService {
var rows=batch.allocations().stream().map(a->bySource.get(a.sourceKey())).toList();
var quantities=batch.allocations().stream().collect(Collectors.toMap(RepartitionFbaAppointmentsRequest.Allocation::sourceKey,RepartitionFbaAppointmentsRequest.Allocation::packageCount));
var row=new InboundFbaAppointment();row.setId(IdWorker.getId());
row.setAppointmentNo("AP-"+LocalDate.now(ZoneOffset.UTC).toString().replace("-","")+"-"+UUID.randomUUID().toString().replace("-","").substring(0,12).toUpperCase(Locale.ROOT));
row.setAppointmentNo(documentNumbers.next("YY"));
row.setOriginWarehouseId(grouped.originWarehouseId());row.setFbaWarehouseCode(rows.get(0).destinationWarehouseCode());row.setSelectedCount(rows.size());row.setVersion(0);
row.setCreatedBy(operator);row.setCreatedAt(now());row.setUpdatedAt(row.getCreatedAt());
apply(row,new FbaAppointmentDetails(batch.plannedDate(),null,null,null,null,null,"PENDING",batch.remark(),null,null),rows,evidence);
@@ -471,7 +475,7 @@ public class InboundFbaAppointmentService {
}
if(laterAmounts.isEmpty()) throw bad("本次货量没有变化,无需创建变化记录");
var laterRows=resolved.stream().filter(s->laterAmounts.containsKey(s.sourceKey())).toList();
var later=new InboundFbaAppointment();later.setId(IdWorker.getId());later.setAppointmentNo("AP-"+LocalDate.now(ZoneOffset.UTC).toString().replace("-","")+"-"+UUID.randomUUID().toString().replace("-","").substring(0,12).toUpperCase(Locale.ROOT));
var later=new InboundFbaAppointment();later.setId(IdWorker.getId());later.setAppointmentNo(documentNumbers.next("YY"));
later.setOriginWarehouseId(original.getOriginWarehouseId());later.setFbaWarehouseCode(original.getFbaWarehouseCode());later.setVersion(0);later.setSelectedCount(laterRows.size());later.setCreatedBy(operator);later.setCreatedAt(now());later.setUpdatedAt(now());
apply(later,new FbaAppointmentDetails(intent.laterDate(),null,null,null,null,null,"PENDING",intent.reason(),null,null),laterRows,evidence);
appointmentMapper.insert(later);writeItems(later,laterRows,List.of(),evidence,laterAmounts);
@@ -456,6 +456,7 @@ public class InboundPlanService {
receivingWarehouse, plannedArrivalDate, null, Map.of());
}
/** 创建入仓计划,复用预分配编号或首次分配 IP 日流水。 */
private InboundPlanResponse createPlan(String customerId, Long selectedOrderId,
List<SourceOrderSnapshot> orders, List<String> sourcePalletNos,
List<WholePalletInput> wholePallets, ManualInboundPlanRequest manual,
@@ -468,7 +469,7 @@ public class InboundPlanService {
InboundPlan plan = new InboundPlan();
plan.setId(preparedPlan == null ? IdWorker.getId() : preparedPlan.planId());
plan.setPlanNo(preparedPlan == null
? orderNoGenerator.next("INBOUND_PLAN", orders.get(0).destinationWarehouseCode(), plan.getId())
? orderNoGenerator.next("INBOUND_PLAN")
: preparedPlan.planNo());
plan.setCustomerId(customerId);
plan.setSourceOrderId(selectedOrderId);
@@ -63,7 +63,7 @@ public class LocalLooseboardService {
LocalLooseboardOrder order = new LocalLooseboardOrder();
order.setId(IdWorker.getId());
order.setCustomerId(customerId);
order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", request.destination(), order.getId()));
order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD"));
apply(order, request.origin(),
request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(),
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(),
@@ -276,7 +276,7 @@ public class LocalLooseboardService {
private LastlegOrder createLastlegOrder(LocalLooseboardOrder source, String requestHash, LocalDateTime now) {
LastlegOrder order = new LastlegOrder();
order.setId(IdWorker.getId());
order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", source.getDestination(), order.getId()));
order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD"));
order.setCustomerId(source.getCustomerId());
order.setOrderType("LOCAL_LOOSEBOARD");
order.setInboundCode(null);
@@ -1,19 +0,0 @@
package com.baiyue.lastleg.mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDate;
@Mapper
public interface LastlegOrderNoDailySequenceMapper {
@Insert("INSERT INTO ll_order_no_daily_sequence(business_date,last_sequence) " +
"VALUES(#{businessDate},LAST_INSERT_ID(1)) " +
"ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)")
int nextValue(@Param("businessDate") LocalDate businessDate);
@Select("SELECT LAST_INSERT_ID()")
long currentValue();
}
@@ -33,6 +33,7 @@ public class OverseasProcurementFee {
private Long sourceFeeId;
private Long parentFeeId;
private String requestHash;
private String idempotencyKey;
private String feeItemCode;
private String feeItemName;
private BigDecimal amount;
@@ -3,6 +3,9 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface OverseasProcurementFeeMapper extends BaseMapper<OverseasProcurementFee> {
/** 调用方持有订单锁时,读取当前退款版本上界。 */
@org.apache.ibatis.annotations.Select("SELECT COALESCE(MAX(fee_version),0) FROM ll_overseas_procurement_fee WHERE order_id=#{orderId} AND purpose='REFUND'")
int maxRefundVersion(@org.apache.ibatis.annotations.Param("orderId") long orderId);
@org.apache.ibatis.annotations.Select("SELECT * FROM ll_overseas_procurement_fee WHERE id=#{id} AND order_id=#{orderId} AND customer_id=#{customerId} FOR UPDATE")
OverseasProcurementFee lock(@org.apache.ibatis.annotations.Param("id") long id,
@org.apache.ibatis.annotations.Param("orderId") long orderId,
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.procurement.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper;
import org.springframework.stereotype.Repository;
/** 采购退款的请求身份独立于展示单号,跨日重试仍定位原费用。 */
@Repository
public class ProcurementFeeNumberRepository {
private final OverseasProcurementFeeMapper mapper;
/** 使用采购费用所属服务的 Mapper。 */
public ProcurementFeeNumberRepository(OverseasProcurementFeeMapper mapper) { this.mapper = mapper; }
/** 上层已持有订单锁,同客户同订单的退款请求只对应一个费用。 */
public OverseasProcurementFee findRefund(String customerId, long orderId, String key) {
return mapper.selectOne(new LambdaQueryWrapper<OverseasProcurementFee>()
.eq(OverseasProcurementFee::getCustomerId, customerId)
.eq(OverseasProcurementFee::getOrderId, orderId)
.eq(OverseasProcurementFee::getPurpose, "REFUND")
.eq(OverseasProcurementFee::getIdempotencyKey, key));
}
/** 版本沿用同订单退款序列,业务编号不承担费用版本含义。 */
public int nextRefundVersion(long orderId) { return mapper.maxRefundVersion(orderId) + 1; }
}
@@ -43,7 +43,7 @@ import org.springframework.web.multipart.MultipartFile;
@Service
public class OverseasProcurementService {
@Resource private ProcurementOrderNumberGenerator orderNoGenerator;
@Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator orderNoGenerator;
public static final String REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW;
public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT;
public static final String PENDING_REVIEW = "PENDING_REVIEW";
@@ -61,6 +61,7 @@ public class OverseasProcurementService {
@Resource private OverseasProcurementFeeMapper procurementFees;
@Resource private ProcurementFileGateway fileGateway;
/** 创建客户采购订单,首次请求分配 CG 日流水。 */
@Transactional
public OverseasProcurementResponses.Detail create(String customerId, String operatorId,
OverseasProcurementRequests.Create request) {
@@ -74,7 +75,7 @@ public class OverseasProcurementService {
order.setId(IdWorker.getId());
order.setCustomerId(customerId);
order.setCustomerName(trim(request.customerName()));
order.setOrderNo(orderNoGenerator.next(customerId, request.customerName()));
order.setOrderNo(orderNoGenerator.next("CG"));
order.setPurchaseCategory(request.purchaseCategory() == null ? "" : request.purchaseCategory().trim());
order.setProductName(request.productName().trim());
order.setProductSpecification(trim(request.productSpecification()));
@@ -16,6 +16,8 @@ import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService
@Service @RequiredArgsConstructor
public class ProcurementAdjustmentService {
@jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers;
private final ProcurementLifecycleService lifecycle;
private final ProcurementFinanceService finance;
private final JdbcTemplate jdbc;
@@ -23,6 +25,7 @@ public class ProcurementAdjustmentService {
BigDecimal exchangeRate,String exchangeRateMonth,BigDecimal targetAmount,BigDecimal previousNetAmount,
BigDecimal differenceAmount,List<String> feeNos) { }
private record Identity(long rootFeeId,Correct request) { }
/** 更正原采购费用并以 PG 日流水标识本次更正记录。 */
@Transactional
public Result correct(String customer,String orderNo,String operator,long rootFeeId,Correct request) {
var order=lifecycle.lock(customer,orderNo);var identity=new Identity(rootFeeId,request);
@@ -37,7 +40,7 @@ public class ProcurementAdjustmentService {
.subtract(chain.stream().filter(f->"REFUNDED".equals(f.getWalletStatus())).map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add));
BigDecimal target=request.targetOriginalAmount().multiply(root.getExchangeRate()).setScale(2,RoundingMode.HALF_UP),delta=target.subtract(net);
if(target.precision()-target.scale()>18)throw bad("折算人民币金额超出支持范围");
long id=IdWorker.getId();String no="PC-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase();
long id=IdWorker.getId();String no=documentNumbers.next("PG");
// The node command carries the exact request hash; this table stores the computed immutable monetary facts.
jdbc.update("INSERT INTO ll_procurement_fee_correction(id,order_id,customer_id,correction_no,root_fee_id,idempotency_key,request_hash,target_original_amount,original_currency,exchange_rate,exchange_rate_month,target_amount,previous_net_amount,difference_amount,voucher_file_id,reason,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
id,order.getId(),customer,no,rootFeeId,request.idempotencyKey(),lifecycle.requestHash(identity),request.targetOriginalAmount(),root.getOriginalCurrency(),root.getExchangeRate(),root.getExchangeRateMonth(),target,net,delta,request.voucherFileId(),request.reason(),operator);
@@ -47,6 +50,7 @@ public class ProcurementAdjustmentService {
lifecycle.complete(order,operator,"CORRECT_FEE_TARGET",request.idempotencyKey(),request.reason(),identity);
return result(order.getId(),request.idempotencyKey());
}
/** 替换失败费用,保留原费用链并分配新的 PG 更正记录号。 */
@Transactional
public Result replaceFailed(String customer,String orderNo,String operator,long originalFeeId,Correct request) {
var order=lifecycle.lock(customer,orderNo);var identity=new Identity(originalFeeId,request);
@@ -57,7 +61,7 @@ public class ProcurementAdjustmentService {
BigDecimal target=request.targetOriginalAmount().multiply(original.getExchangeRate()).setScale(2,RoundingMode.HALF_UP);
if("SAMPLE_PAYMENT".equals(original.getPurpose()))lifecycle.requireReplacementBudget(order,request.targetOriginalAmount(),original.getOriginalCurrency(),original.getBusinessDate(),target);
if(target.precision()-target.scale()>18)throw bad("折算人民币金额超出支持范围");
long id=IdWorker.getId();String no="PC-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase();
long id=IdWorker.getId();String no=documentNumbers.next("PG");
jdbc.update("INSERT INTO ll_procurement_fee_correction(id,order_id,customer_id,correction_no,root_fee_id,idempotency_key,request_hash,target_original_amount,original_currency,exchange_rate,exchange_rate_month,target_amount,previous_net_amount,difference_amount,voucher_file_id,reason,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
id,order.getId(),customer,no,originalFeeId,request.idempotencyKey(),lifecycle.requestHash(identity),request.targetOriginalAmount(),original.getOriginalCurrency(),original.getExchangeRate(),original.getExchangeRateMonth(),target,BigDecimal.ZERO,target,request.voucherFileId(),request.reason(),operator);
var fee=finance.replaceFailed(customer,orderNo,operator,originalFeeId,request.targetOriginalAmount(),request.voucherFileId(),request.reason());
@@ -13,6 +13,8 @@ import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService
@Service @RequiredArgsConstructor
public class ProcurementControlService {
@jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers;
private final ProcurementLifecycleService lifecycle;
private final ProcurementFinanceService finance;
private final ProcurementWarehouseDirectory warehouses;
@@ -141,5 +143,6 @@ public class ProcurementControlService {
return result;
}
private String serialize(Object value){if(value==null)return null;try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}}
private static String number(String prefix){return prefix+'-'+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase();}
/** 说明和关闭记录使用各自独立的日流水。 */
private String number(String prefix){return documentNumbers.next(prefix);}
}
@@ -38,6 +38,9 @@ import java.util.UUID;
@Service
@RequiredArgsConstructor
public class ProcurementFinanceService implements WalletResultHandler {
@jakarta.annotation.Resource private com.baiyue.lastleg.procurement.repository.ProcurementFeeNumberRepository feeNumbers;
@jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers;
private final OverseasProcurementFeeMapper fees;
private final OverseasProcurementFileMapper files;
private final OverseasProcurementOrderMapper orders;
@@ -118,7 +121,7 @@ public class ProcurementFinanceService implements WalletResultHandler {
LocalDateTime now = LocalDateTime.now();
OverseasProcurementFee fee = new OverseasProcurementFee();
fee.setId(IdWorker.getId()); fee.setOrderId(order.getId()); fee.setCustomerId(order.getCustomerId());
fee.setFeeNo("PF-" + UUID.randomUUID().toString().replace("-", "").substring(0, 24).toUpperCase());
fee.setFeeNo(documentNumbers.next("PF"));
fee.setPurpose(request.purpose().name()); fee.setConfirmationOrigin(origin); fee.setFeeVersion(maxVersion == null ? 1 : maxVersion + 1);
fee.setActive(true); fee.setDetermination(request.determination()); fee.setFeeItemCode(code);
fee.setFeeItemName(request.purpose() == ProcurementFeePurpose.OTHER ? request.feeItemName().trim() : request.purpose().label());
@@ -205,6 +208,7 @@ public class ProcurementFinanceService implements WalletResultHandler {
var order = lockOrder(customerId, orderNo);
return refundLocked(order,operatorId,originalFeeId,amount,voucherFileId,reason,idempotencyKey,true);
}
/** 订单锁内先恢复同幂等键退款,首次请求校验后才分配独立 PF 业务号。 */
private OverseasProcurementFee refundLocked(OverseasProcurementOrder order,String operatorId,long originalFeeId,
BigDecimal amount,long voucherFileId,String reason,String idempotencyKey,boolean checkChain) {
String customerId=order.getCustomerId(),orderNo=order.getOrderNo();
@@ -212,11 +216,8 @@ public class ProcurementFinanceService implements WalletResultHandler {
var request = new Confirmation(ProcurementFeePurpose.REFUND, "REFUND", "退款", "POSITIVE", amount,
"CNY", LocalDate.now(), voucherFileId, reason);
validate(request);
// Identity is independent of wall-clock time, so retrying after midnight returns the same refund.
String number = "RF-" + UUID.nameUUIDFromBytes((customerId + ':' + orderNo + ':' + idempotencyKey)
.getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString().replace("-", "").toUpperCase();
String requestHash = hash(List.of(originalFeeId, amount, voucherFileId, reason));
var existing = fees.selectOne(new LambdaQueryWrapper<OverseasProcurementFee>().eq(OverseasProcurementFee::getFeeNo, number));
var existing = feeNumbers.findRefund(customerId, order.getId(), idempotencyKey);
if (existing != null) {
if (!requestHash.equals(existing.getRequestHash())) throw conflict("退款幂等键对应不同原扣款或金额");
return existing;
@@ -237,10 +238,11 @@ public class ProcurementFinanceService implements WalletResultHandler {
if (already.add(amount).compareTo(original.getAmount()) > 0) throw bad("退款超过原扣款可退余额");
var voucher = voucher(order, request, true);
var fee = new OverseasProcurementFee();
String number = documentNumbers.next("PF");
fee.setIdempotencyKey(idempotencyKey);
fee.setId(IdWorker.getId()); fee.setOrderId(order.getId()); fee.setCustomerId(customerId); fee.setFeeNo(number);
fee.setConfirmationOrigin("FINANCIAL");fee.setPurpose("REFUND"); fee.setFeeItemCode("REFUND"); fee.setFeeItemName("关联费用退款");
Integer last = jdbc.queryForObject("SELECT COALESCE(MAX(fee_version),0) FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose='REFUND'", Integer.class, order.getId());
fee.setFeeVersion(last == null ? 1 : last + 1); fee.setActive(true); fee.setDetermination("POSITIVE");
fee.setFeeVersion(feeNumbers.nextRefundVersion(order.getId())); fee.setActive(true); fee.setDetermination("POSITIVE");
fee.setAmount(amount); fee.setCurrency("CNY"); fee.setOriginalAmount(amount); fee.setOriginalCurrency("CNY");
fee.setExchangeRate(BigDecimal.ONE); fee.setExchangeRateMonth(request.businessDate().toString().substring(0, 7));
fee.setBusinessDate(request.businessDate()); fee.setParentFeeId(originalFeeId); fee.setWalletBusinessId(fee.getId());
@@ -272,7 +274,7 @@ public class ProcurementFinanceService implements WalletResultHandler {
requireSourceCapacity(delta,delta);
var proof=voucher(order,new Confirmation(ProcurementFeePurpose.ADJUSTMENT,"ADJUSTMENT","追加费用","POSITIVE",delta,"CNY",root.getBusinessDate(),voucherId,reason),true);
var fee=new OverseasProcurementFee();fee.setId(IdWorker.getId());fee.setOrderId(order.getId());fee.setCustomerId(customer);
fee.setFeeNo("PF-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase());fee.setConfirmationOrigin("FINANCIAL");fee.setPurpose("ADJUSTMENT");
fee.setFeeNo(documentNumbers.next("PF"));fee.setConfirmationOrigin("FINANCIAL");fee.setPurpose("ADJUSTMENT");
fee.setFeeItemCode("ADJ_"+root.getId());fee.setFeeItemName("原费用正额调整");
fee.setFeeVersion(jdbc.queryForObject("SELECT COALESCE(MAX(fee_version),0)+1 FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose='ADJUSTMENT' AND fee_item_code=?",Integer.class,order.getId(),fee.getFeeItemCode()));
fee.setActive(true);fee.setDetermination("POSITIVE");fee.setAmount(delta);fee.setCurrency("CNY");fee.setOriginalCurrency("CNY");fee.setOriginalAmount(delta);
@@ -1,48 +0,0 @@
package com.baiyue.lastleg.procurement.service;
import net.sourceforge.pinyin4j.PinyinHelper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
@Component
public class ProcurementOrderNumberGenerator {
private final JdbcTemplate jdbc;
public ProcurementOrderNumberGenerator(JdbcTemplate jdbc) { this.jdbc = jdbc; }
@Transactional
public String next(String customerId, String customerName) {
LocalDate date = LocalDate.now();
jdbc.update("INSERT INTO ll_procurement_daily_sequence(customer_id,business_date,sequence_value) VALUES(?,?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE sequence_value=LAST_INSERT_ID(sequence_value+1)",
customerId, date);
Integer value = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Integer.class);
if (value == null || value < 1 || value > 9999) throw new IllegalStateException("客户采购订单当日序号已耗尽");
return initials(customerName) + date.format(DateTimeFormatter.BASIC_ISO_DATE) + String.format("%04d", value);
}
static String initials(String customerName) {
if (!StringUtils.hasText(customerName)) return "KH";
StringBuilder result = new StringBuilder();
boolean latinWord = false;
for (char value : customerName.trim().toCharArray()) {
if (result.length() >= 8) break;
if (Character.UnicodeScript.of(value) == Character.UnicodeScript.HAN) {
String[] pinyin = PinyinHelper.toHanyuPinyinStringArray(value);
if (pinyin != null && pinyin.length > 0 && !pinyin[0].isBlank()) result.append(Character.toUpperCase(pinyin[0].charAt(0)));
latinWord = false;
} else if (Character.isLetterOrDigit(value)) {
if (!latinWord) result.append(Character.toUpperCase(value));
latinWord = true;
} else {
latinWord = false;
}
}
String normalized = result.toString().replaceAll("[^A-Z0-9]", "").toUpperCase(Locale.ROOT);
return normalized.isBlank() ? "KH" : normalized.substring(0, Math.min(normalized.length(), 8));
}
}
@@ -19,6 +19,8 @@ import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService
@Service
@RequiredArgsConstructor
public class ProcurementPhysicalService {
@jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers;
private final ProcurementLifecycleService lifecycle;
private final ProcurementWarehouseDirectory warehouses;
private final ProcurementFinanceService finance;
@@ -327,5 +329,6 @@ public class ProcurementPhysicalService {
}
private static void measure(BigDecimal value,int scale){if(value==null || value.signum()<=0 || value.scale()>scale)throw bad("箱规须为正数且最多两位小数,毛重须为正数且最多三位小数");}
private static long add(long a,long b){try{return Math.addExact(a,b);}catch(ArithmeticException e){throw bad("数量超过可处理范围");}}
private static String number(String prefix){return prefix+'-'+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase();}
/** 实收记录使用独立 PR 日流水。 */
private String number(String prefix){return documentNumbers.next(prefix);}
}
@@ -19,6 +19,8 @@ import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService
/** Every shipment is a single immutable consumption of this order's entire eligible batch. */
@Service @RequiredArgsConstructor
public class ProcurementTransportService {
@jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers;
private final ProcurementLifecycleService lifecycle;
private final ProcurementPhysicalService physical;
private final ProcurementFinanceService finance;
@@ -142,6 +144,7 @@ public class ProcurementTransportService {
return snapshot(order);
}
/** 确认采购交寄并记录 PJ 日流水,保留原单关联与重放结果。 */
@Transactional
public Snapshot ship(String customer,String orderNo,String operator,Ship request) {
var order=lifecycle.lock(customer,orderNo);
@@ -155,7 +158,7 @@ public class ProcurementTransportService {
} else {
if(request.shippedAt().isBefore(((LocalDateTime)flow.get("domestic_received_at"))))throw bad("交寄时间不能早于国内收货完成");
}
long id=IdWorker.getId();String number="PS-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase();
long id=IdWorker.getId();String number=documentNumbers.next("PJ");
jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(?,?,?,?,?,?,?,CAST(? AS JSON),?)",
id,order.getId(),customer,number,request.leg().name(),labels.manifestId(),request.shippedAt(),serialize(new Addresses(labels.sender(),labels.receiver())),operator);
for(var carton:cargo) {
@@ -80,7 +80,7 @@ public class RelabelApplicationService implements WalletResultHandler {
READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED");
private static final Set<String> DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "AMAZON_PICKUP", "CUSTOMER_PICKUP", "PICKUP");
@Resource private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
@Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers;
@Resource private RelabelApplicationMapper applications;
@Resource private com.baiyue.lastleg.relabel.repository.RelabelStatusRepository statusRepository;
@Resource private RelabelItemMapper items;
@@ -361,6 +361,7 @@ public class RelabelApplicationService implements WalletResultHandler {
return create(customerId, operatorId, request, existingTailOrderId);
}
/** 创建换标申请,使用 TB 日流水并保留来源货物关联。 */
private RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request,
Long existingTailOrderId) {
if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required");
@@ -488,7 +489,7 @@ public class RelabelApplicationService implements WalletResultHandler {
LocalDateTime now = LocalDateTime.now();
long applicationId = IdWorker.getId();
String operationWarehouseId = request.operationWarehouseId().trim();
String relabelNo = compactNumbers.next("TB", customerId);
String relabelNo = compactNumbers.next("TB");
BigDecimal chargeAmount = Objects.requireNonNullElse(request.chargeAmount(), BigDecimal.ZERO);
boolean requiresWallet = chargeAmount.signum() > 0;
int holdIndex = 0;
@@ -1065,6 +1066,7 @@ public class RelabelApplicationService implements WalletResultHandler {
return detail(customerId, relabelNo);
}
/** 建立换标出库与派送单,分别分配 HBCK 和 DL 业务号。 */
private void createDirectOutbound(RelabelApplication application, String operatorId, LocalDateTime now) {
String key = "RELABEL:" + application.getId() + ":OUTBOUND";
OutboundOrder existing = outboundOrders.findByIdempotency(application.getCustomerId(), key);
@@ -1103,7 +1105,7 @@ public class RelabelApplicationService implements WalletResultHandler {
}
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId());
outbound.setOutboundNo(orderNoGenerator.next("RELABEL_OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
outbound.setOutboundNo(orderNoGenerator.next("RELABEL_OUTBOUND"));
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType("RELABEL_OUTBOUND");
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(relabelItems.stream().mapToInt(item -> item.getReturnCartonId() == null ? intValue(item.getNotifiedPackageCount()) : 1).sum());
outbound.setPalletCount((int) relabelItems.stream().map(this::outboundSourceKey).distinct().count());
@@ -1153,7 +1155,7 @@ public class RelabelApplicationService implements WalletResultHandler {
for (var destination : destinations.entrySet()) {
var delivery = new com.baiyue.lastleg.delivery.entity.DeliveryOrder();
delivery.setId(IdWorker.getId()); delivery.setCustomerId(application.getCustomerId());
delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", application.getOverseasWarehouseId(), delivery.getId()));
delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY"));
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
delivery.setOverseasWarehouseId(application.getOverseasWarehouseId());
delivery.setCustomerName(customerName); delivery.setOverseasWarehouseName(warehouseName);
@@ -189,7 +189,7 @@ public class ReturnInventoryService {
List<ReturnInventoryRequests.PlanBox> boxes = List.of(new ReturnInventoryRequests.PlanBox(
reserved.entrySet().stream().map(e -> new ReturnInventoryRequests.PlanItem(e.getKey(), e.getValue())).toList(), null, null, null, null));
ReturnPackingPlan plan = new ReturnPackingPlan();
plan.setId(IdWorker.getId()); plan.setPlanNo(numbers.next("RETURN_PACKING_PLAN", first.getOverseasWarehouseId(), plan.getId()));
plan.setId(IdWorker.getId()); plan.setPlanNo(numbers.next("RETURN_PACKING_PLAN"));
plan.setCustomerId(first.getCustomerId()); plan.setOverseasWarehouseId(first.getOverseasWarehouseId());
plan.setReturnNosJson(json(returnNos)); plan.setBoxesJson(json(boxes)); plan.setSourceItemsJson(json(allocation));
plan.setMode(mode); plan.setStatus("PENDING_REVIEW"); plan.setRemark(request.remark()); plan.setIdempotencyKey(request.idempotencyKey());
@@ -540,7 +540,7 @@ public class ReturnInventoryService {
long cartonId = IdWorker.getId();
// 包裹签收已生成 PK 标签,后续库存、废弃和扫码沿用同一编码。
if ("PARCEL".equals(receipt.getUnitType())) cartonMark = normalizedUnitCode;
else if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId);
else if (cartonMark == null) cartonMark = LastlegOrderNoGenerator.compactCartonMark(cartonId);
if (cartons.findMark(cartonMark) != null) throw conflict("箱号已存在: " + cartonMark);
LocalDateTime now = LocalDateTime.now();
long total = lines.stream().mapToLong(ReceiptLine::quantity).sum();
@@ -984,6 +984,7 @@ public class ReturnInventoryService {
return mergeScoped(identity, request, limits, targetMark, null);
}
/** 在当前仓范围执行合箱,目标实物箱码继续使用原标签算法。 */
private ReturnInventoryResponses.MergeResult mergeScoped(PdaWarehouseIdentity identity, ReturnInventoryRequests.Merge request,
Map<String, Map<String, Long>> limits, String targetMark, String generatedMark) {
String requestHash = targetMark == null ? hash(request) : hash(List.of(request, targetMark, limits.keySet()));
@@ -1122,7 +1123,7 @@ public class ReturnInventoryService {
// A merged carton may remain loose; pallet binding is an explicit warehouse operation.
target.setSourceUnitType("BOX");
target.setId(targetId); target.setCustomerId(customerId); target.setOverseasWarehouseId(sources.get(0).getOverseasWarehouseId());
if (existingTarget == null) target.setCartonMark(generatedMark == null ? numbers.next("RETURN_CARTON", target.getOverseasWarehouseId(), targetId) : generatedMark);
if (existingTarget == null) target.setCartonMark(generatedMark == null ? LastlegOrderNoGenerator.compactCartonMark(targetId) : generatedMark);
if (existingTarget != null) {
if (text(target.getSourceBatchNo()) != null) consumedBatchNos.addAll(java.util.Arrays.asList(target.getSourceBatchNo().split(",")));
if (text(target.getSourceReturnNo()) != null) consumedReturnNos.addAll(java.util.Arrays.asList(target.getSourceReturnNo().split(",")));
@@ -1276,6 +1277,7 @@ public class ReturnInventoryService {
+ "</div></body></html>";
}
private static String dimension(BigDecimal value) { return value == null ? "—" : value.stripTrailingZeros().toPlainString(); }
/** 按合箱分组生成实物箱唛,保留完整 ID 编码和分箱后缀。 */
private List<String> packingMarks(List<ReturnInventoryRequests.PlanBox> boxes) {
List<List<String>> groups = boxes.stream().map(box -> box.items() == null ? List.<String>of() : box.items().stream()
.map(ReturnInventoryRequests.PlanItem::skuCode).distinct().sorted().toList()).toList();
@@ -62,7 +62,7 @@ public class ReturnReceiptService {
public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED";
public static final String COMPLETED = "COMPLETED";
@Resource private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
@Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers;
@Resource private ReturnOrderMapper orders;
@Resource private ReturnOrderItemMapper orderItems;
@Resource private ReturnReceiptMapper receipts;
@@ -81,6 +81,7 @@ public class ReturnReceiptService {
@Resource private com.baiyue.lastleg.service.FbaWarehouseValidator fbaWarehouseValidator;
@Resource private com.baiyue.lastleg.service.ReturnOrderReferenceResolver returnOrderReferences;
/** 创建退货订单并分配 TH 日流水,实物标签继续沿用原规则。 */
@Transactional
public ReturnOrderResponses.Detail create(String customerId, String operatorId, ReturnOrderRequests.Create request) {
String key = text(request.idempotencyKey());
@@ -100,7 +101,7 @@ public class ReturnReceiptService {
order.setOverseasWarehouseId(text(request.overseasWarehouseId()));
order.setWarehouseContactName(text(request.warehouseContactName()));
order.setWarehouseContactPhone(text(request.warehouseContactPhone()));
order.setReturnNo(compactNumbers.next("TT", customerId));
order.setReturnNo(compactNumbers.next("TH"));
order.setCustomerReferenceNo(text(request.customerReferenceNo())); order.setNeedRelabel(request.needRelabel()); order.setStatus(PENDING_REVIEW);
order.setExpectedSkuTypes(skuCodes.size()); order.setExpectedQuantity(expected); order.setReceivedQuantity(0L);
order.setRemark(text(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(requestHash);
@@ -396,6 +397,7 @@ public class ReturnReceiptService {
return result;
}
/** 从退货创建换标业务并分配 THHB 编号,重放沿用原记录。 */
@Transactional
public ReturnOrderResponses.RelabelCreated createRelabel(String customerId, String operatorId, String returnNo,
ReturnOrderRequests.RelabelCreate request) {
@@ -430,7 +432,7 @@ public class ReturnReceiptService {
totalQuantity = Math.addExact(totalQuantity, requested.quantity());
}
long appId = IdWorker.getId(); LocalDateTime now = LocalDateTime.now();
String relabelNo = compactNumbers.next("TT", customerId);
String relabelNo = compactNumbers.next("THHB");
LastlegOrder tail = order.getLastlegOrderId() == null ? null : lastlegOrders.selectById(order.getLastlegOrderId());
if (tail == null) {
tail = new LastlegOrder(); tail.setId(IdWorker.getId()); tail.setOrderNo(relabelNo);
@@ -569,6 +571,7 @@ public class ReturnReceiptService {
return detail(order.getCustomerId(), order.getReturnNo());
}
/** 确认退货实收,新增实物箱码沿用原编码,不使用单据日流水。 */
@Transactional
public PdaReturnReceiptResponses.Confirmed confirm(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Confirm request) {
String key = text(request.idempotencyKey());
@@ -643,8 +646,8 @@ public class ReturnReceiptService {
long receiptId = IdWorker.getId();
ReturnReceipt receipt = new ReturnReceipt();
receipt.setId(receiptId); receipt.setReturnOrderId(order.getId()); receipt.setCustomerId(order.getCustomerId());
receipt.setReceiptNo(numbers.next("RETURN_RECEIPT", order.getOverseasWarehouseId(), receiptId));
receipt.setReceiptBatchNo(numbers.next("RETURN_BATCH", order.getOverseasWarehouseId(), receiptId));
receipt.setReceiptNo(numbers.next("RETURN_RECEIPT"));
receipt.setReceiptBatchNo(numbers.next("RETURN_BATCH"));
receipt.setTrackingNo(trackingNo); receipt.setBoundBy(startedBy); receipt.setStatus("SIGNED");
receipt.setUnitType(unitType); receipt.setUnitCode(unitCode);
receipt.setTotalBoxCount(totalBoxCount);
@@ -1,59 +0,0 @@
package com.baiyue.lastleg.service;
import net.sourceforge.pinyin4j.PinyinHelper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
/** Allocates suffix-free document numbers across all service instances. */
@Component
public class CompactBusinessNumberService {
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter FORMAT = DateTimeFormatter.ofPattern("yyMMddHHmmss", Locale.ROOT).withZone(ZONE);
private final JdbcTemplate jdbc;
private final ReturnOrderReferenceResolver customers;
public CompactBusinessNumberService(JdbcTemplate jdbc, ReturnOrderReferenceResolver customers) {
this.jdbc = jdbc;
this.customers = customers;
}
@Transactional
public String next(String prefix, String customerId) {
return allocate(prefix, initials(customers.customerName(customerId)), Instant.now());
}
String allocate(String prefix, String initials, Instant now) {
String key = prefix + "-" + initials;
long seconds = now.getEpochSecond();
// The row lock serializes identical prefixes/initials even across customers and JVMs.
jdbc.update("INSERT INTO ll_compact_number_clock(number_key,last_second) VALUES(?,LAST_INSERT_ID(?)) "
+ "ON DUPLICATE KEY UPDATE last_second=LAST_INSERT_ID(GREATEST(last_second+1,?))", key, seconds, seconds);
Long allocated = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
if (allocated == null || allocated < seconds) throw new IllegalStateException("业务编号生成失败");
return key + "-" + FORMAT.format(Instant.ofEpochSecond(allocated));
}
static String initials(String name) {
if (name == null || name.isBlank()) return "KH";
StringBuilder result = new StringBuilder();
boolean latinWord = false;
for (char c : name.trim().toCharArray()) {
if (result.length() >= 4) break;
if (Character.UnicodeScript.of(c) == Character.UnicodeScript.HAN) {
String[] pinyin = PinyinHelper.toHanyuPinyinStringArray(c);
if (pinyin != null && pinyin.length > 0) result.append(Character.toUpperCase(pinyin[0].charAt(0)));
latinWord = false;
} else if (c < 128 && Character.isLetterOrDigit(c)) {
if (!latinWord) result.append(Character.toUpperCase(c));
latinWord = true;
} else latinWord = false;
}
return result.isEmpty() ? "KH" : result.toString();
}
}
@@ -1,122 +1,58 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.mapper.LastlegOrderNoDailySequenceMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.UUID;
import java.math.BigInteger;
import java.nio.ByteBuffer;
/** 海外单据类型映射;头程来源尾程号与实物箱码保持本次确认的范围边界。 */
@Component
public class LastlegOrderNoGenerator {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter BUSINESS_DATE = DateTimeFormatter.ofPattern("yyMMdd");
@Resource private LastlegOrderNoDailySequenceMapper sequenceMapper;
private final LastlegDocumentNumberGenerator documents;
public String next() {
return next(LocalDate.now(BUSINESS_ZONE));
/** 所有海外业务入口共用所属服务的日流水。 */
public LastlegOrderNoGenerator(LastlegDocumentNumberGenerator documents) {
this.documents = documents;
}
String next(LocalDate businessDate) {
sequenceMapper.nextValue(businessDate);
long sequence = sequenceMapper.currentValue();
if (sequence < 1 || sequence > 9999) {
throw new IllegalStateException("尾程单日流水超出 0001-9999 范围: " + businessDate);
}
return "LMO-" + BUSINESS_DATE.format(businessDate) + "-" + String.format("%04d", sequence);
}
public String next(String businessType, String warehouse, long uniqueId) {
String code = businessType == null ? "" : businessType.trim().toUpperCase(Locale.ROOT);
if ("RETURN_CARTON".equals(code)) return compactCartonMark(uniqueId);
String returnPrefix = switch (code) {
case "RETURN" -> "RT";
/** 明确业务类型对应的前缀,未知类型不再截断名称或使用内部 ID 回退。 */
public String next(String businessType) {
String prefix = switch (businessType) {
case "ONE_CLICK_DROPSHIP" -> "DFCK";
case "OUTBOUND" -> "CK";
case "RELABEL_OUTBOUND" -> "HBCK";
case "RETURN_OUTBOUND" -> "THCK";
case "DELIVERY" -> "DL";
case "TRUCK" -> "TR";
case "LOOSEBOARD" -> "LB";
case "EXCEPTION" -> "EX";
case "DISCARD" -> "CD";
case "INBOUND_PLAN" -> "IP";
case "RETURN_RECEIPT" -> "RR";
case "RETURN_BATCH" -> "RB";
case "RETURN_CARTON" -> "RC";
case "RETURN_RELABEL" -> "RETU";
default -> null;
case "RETURN_PACKING_PLAN" -> "HX";
default -> throw new IllegalArgumentException("未登记的海外单据类型:" + businessType);
};
// Return numbers are independent business identifiers, not truncated database IDs.
// Keep every UUID bit; the longest result (RETU + 25 base-36 digits) fits VARCHAR(32).
if (returnPrefix != null) return returnPrefix + "-" + encodeReturnUuid(UUID.randomUUID());
// Transfer numbers also identify synthetic inbound events globally. Truncating
// a database ID to six digits can collide between unrelated fresh transfers.
if ("TRANSFER".equals(code) || "WAREHOUSE_TRANSFER".equals(code)) {
return "WT-" + encodeReturnUuid(UUID.randomUUID());
}
String type = normalizeType(businessType);
return type + "-" + warehouseCode(warehouse) + "-"
+ LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-"
+ String.format(Locale.ROOT, "%06d", Math.floorMod(uniqueId, 1_000_000));
return documents.next(prefix);
}
static String encodeReturnUuid(UUID uuid) {
byte[] bytes = ByteBuffer.allocate(16).putLong(uuid.getMostSignificantBits())
.putLong(uuid.getLeastSignificantBits()).array();
String encoded = new BigInteger(1, bytes).toString(36).toUpperCase(Locale.ROOT);
return "0".repeat(25 - encoded.length()) + encoded;
}
/** Preserve every snowflake ID bit; do not truncate or hash physical label identifiers. */
/** 实物箱码保留完整 ID 的三十六进制编码,本次不修改扫码标签。 */
public static String compactCartonMark(long id) {
if (id <= 0) throw new IllegalArgumentException("Carton id must be positive");
return "BX-" + Long.toString(id, 36).toUpperCase(Locale.ROOT);
}
private String normalizeType(String value) {
String code = value == null ? "LMO" : value.trim().toUpperCase(Locale.ROOT);
return switch (code) {
case "FIRST_LEG" -> "FL";
case "ONE_CLICK_DROPSHIP" -> "OD";
case "RELABEL_APPLICATION", "RELABEL" -> "RL";
case "WAREHOUSE_TRANSFER", "TRANSFER" -> "WT";
case "LOCAL_LOOSEBOARD", "LOOSEBOARD" -> "LB";
case "DISPATCH", "PICKUP_CAR" -> "DS";
case "OUTBOUND" -> "OB";
case "DELIVERY" -> "DL";
case "TRUCK" -> "TR";
case "EXCEPTION" -> "EX";
case "DISCARD" -> "CD";
case "PROCUREMENT" -> "PR";
case "INBOUND_PLAN" -> "IP";
default -> {
String normalized = code.replaceAll("[^A-Z0-9]", "");
yield normalized.isEmpty() ? "LMO" : normalized.substring(0, Math.min(4, normalized.length()));
}
};
}
public String warehouseCode(String value) {
String code = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
if (code.isEmpty()) return "WH";
if (code.matches(".*(LAX|ONT|NYC|JFK|LGB|EWR|SFO|SEA|DFW|ORD|ATL|MIA|PHX|LAS|YYZ|YVR|AMS|LHR|FRA|CDG|HKG|NRT|SYD).*")) {
var match = java.util.regex.Pattern.compile("(LAX|ONT|NYC|JFK|LGB|EWR|SFO|SEA|DFW|ORD|ATL|MIA|PHX|LAS|YYZ|YVR|AMS|LHR|FRA|CDG|HKG|NRT|SYD)").matcher(code);
if (match.find()) return match.group(1);
}
if (code.matches("[A-Z0-9]{2,8}")) return code;
return "WH";
}
/** The physical container number is the tail-leg business order grouping boundary. */
/** 独立尾程编号尚未确认,头程装柜来源继续使用原有分组编号。 */
public String forContainerNo(String containerNo) {
if (containerNo == null || containerNo.isBlank()) {
throw new IllegalArgumentException("containerNo is required");
}
String normalized = containerNo.trim();
if (containerNo == null || containerNo.isBlank()) throw new IllegalArgumentException("containerNo is required");
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(normalized.getBytes(StandardCharsets.UTF_8));
byte[] digest = MessageDigest.getInstance("SHA-256").digest(containerNo.trim().getBytes(StandardCharsets.UTF_8));
long suffix = java.nio.ByteBuffer.wrap(digest, 0, 8).getLong();
return next("FIRST_LEG", null, suffix);
return "FL-WH-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-"
+ String.format(Locale.ROOT, "%06d", Math.floorMod(suffix, 1_000_000));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
@@ -96,7 +96,7 @@ public class WarehouseTransferService {
@Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupService pickupService;
/** 退货单在转运来源中的展示编码,避免把退货单号误当成普通入仓码查询。 */
public static final String RETURN_ORDER_PREFIX = "RETURN_ORDER:";
@Resource private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
@Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers;
@Resource private com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory;
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool;
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPoolQueries;
@@ -239,7 +239,7 @@ public class WarehouseTransferService {
}
LocalDateTime now = LocalDateTime.now();
long transferId = IdWorker.getId();
String transferNo = compactNumbers.next("ZY", customerId);
String transferNo = compactNumbers.next("ZY");
String requestedInboundCode = trim(request.inboundCode());
List<Long> selectedIds = request.sourceLastlegOrderIds() == null
? List.of() : request.sourceLastlegOrderIds().stream().distinct().toList();
@@ -2252,6 +2252,7 @@ public class WarehouseTransferService {
return item.getId();
}
/** 保存转运箱信息,优先使用预印箱唛,新增箱唛仍按原标签规则生成。 */
private void insertTransferCarton(String customerId, long transferId, long transferItemId,
String transferNo, int cartonIndex, int totalCartons,
String customerCartonMark, LocalDateTime now, String printedMark) {
@@ -70,7 +70,7 @@ public class UsTruckOrderService {
UsTruckOrder order = new UsTruckOrder();
order.setId(IdWorker.getId());
order.setCustomerId(customerId);
order.setOrderNo(orderNoGenerator.next("TRUCK", request.origin(), order.getId()));
order.setOrderNo(orderNoGenerator.next("TRUCK"));
order.setVehicleType(request.vehicleType());
order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress()));
order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress()));
@@ -35,12 +35,6 @@ CREATE TABLE IF NOT EXISTS ll_container_shipping_gate (
KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence)
) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照';
CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence (
business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配尾程单流水号',
PRIMARY KEY(business_date)
) ENGINE=InnoDB COMMENT='尾程单号按日原子流水';
CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
@@ -3212,16 +3206,6 @@ CREATE TABLE IF NOT EXISTS ll_wallet_event_inbox (
CREATE TABLE IF NOT EXISTS ll_procurement_daily_sequence (
customer_id VARCHAR(128) NOT NULL COMMENT '客户ID',
business_date DATE NOT NULL COMMENT '北京时间业务日期',
sequence_value INT NOT NULL COMMENT '客户当日已分配最大序号',
PRIMARY KEY (customer_id,business_date),
CONSTRAINT chk_ll_procurement_sequence CHECK (sequence_value BETWEEN 1 AND 9999)
) ENGINE=InnoDB COMMENT='海外采购客户日序列';
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_file (
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID',
category VARCHAR(16) NOT NULL COMMENT 'RECEIPT实收、VOUCHER费用、LABEL面单、PURCHASE采购、BUDGET预算、EXCEPTION异常凭证', file_id BIGINT NOT NULL COMMENT 'File服务文件ID',
@@ -3249,6 +3233,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_fee (
settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件',
source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID',
parent_fee_id BIGINT NULL COMMENT '原费用或预估引用',
idempotency_key VARCHAR(128) NULL COMMENT '退款请求幂等键,独立于业务单号',
request_hash CHAR(64) NULL COMMENT '确认请求摘要',
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID',
fee_no VARCHAR(64) NOT NULL COMMENT '费用业务号', fee_item_code VARCHAR(32) NOT NULL COMMENT '费用项编码', fee_item_name VARCHAR(128) NOT NULL COMMENT '费用项名称',
@@ -3259,6 +3244,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_fee (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')),
UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version),
UNIQUE KEY uk_ll_procurement_refund_request(order_id,purpose,idempotency_key),
PRIMARY KEY(id), UNIQUE KEY uk_ll_procurement_fee_no(fee_no), KEY idx_ll_procurement_fee_order(customer_id,order_id,created_at),
CONSTRAINT chk_ll_procurement_fee_amount CHECK(amount>=0), CONSTRAINT chk_ll_procurement_fee_currency CHECK(currency='CNY')
) ENGINE=InnoDB COMMENT='海外采购费用明细及资金生命周期';
@@ -4501,11 +4487,6 @@ CREATE TABLE IF NOT EXISTS ll_appointment_work_history (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令';
-- 新单据短号:相同业务及客户名称缩写共享逻辑秒,避免同秒或多实例重号。
CREATE TABLE IF NOT EXISTS ll_compact_number_clock (
number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写',
last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒',
PRIMARY KEY (number_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟';
-- 库存转运无需再次预约入仓;目标库:lastleg-service 所属数据库。可重复执行。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_arrival_at' AND is_nullable='NO'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT ''预计入仓时间,库存转运不填写''', 'SELECT 1'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -4667,3 +4648,11 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
PRIMARY KEY(dispatch_no,request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
-- 统一业务单号:前缀 + yyyyMMdd + 四位日流水。
CREATE TABLE IF NOT EXISTS ll_document_sequence (
sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键',
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
PRIMARY KEY (sequence_key),
CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
@@ -888,8 +888,8 @@ paths:
'422': { description: 排车单没有可打印的 BOL 明细 }
/api/platform/v1/last-leg/dispatches/candidates:
get:
summary: 读取真实候选及来源版本
description: 权限 platform:overseas-warehouse:dispatch:list。
summary: 分页读取真实候选及来源版本
description: 权限 platform:overseas-warehouse:dispatch:list。数据库按客户、订单、托盘和来源身份稳定排序分页;总数与当前页来自同一只读快照。selectionGroup仍包含完整同托及整申请来源,可包含页外成员;选取时服务端再次权威解析,不以当前页充当完整组。
security:
- bearerAuth: []
responses:
@@ -947,6 +947,26 @@ paths:
in: query
schema:
type: string
- name: page
in: query
schema:
type: integer
minimum: 1
default: 1
- name: size
in: query
schema:
type: integer
minimum: 1
maximum: 50
default: 20
- name: warehouseTab
in: query
description: 对起运仓名称和编码的服务端页签筛选,在统计和分页前生效。
schema:
type: string
enum: [ALL, LAX, NYC]
default: ALL
/api/platform/v1/last-leg/dispatches/drafts/pre-arrange:
post:
summary: 将所有非空容器原子转换为整车
@@ -6972,6 +6992,19 @@ components:
nullable: true
complete:
type: boolean
description: 仅当前响应包含筛选后的全部候选时为true;分页中的false不影响每个selectionGroup的完整性。
total:
type: string
pattern: ^[0-9]+$
description: 同一筛选下的来源总行数。
page:
type: integer
size:
type: integer
pages:
type: string
pattern: ^[0-9]+$
required: [candidates, snapshotAt, complete, total, page, size, pages]
DispatchDraftContainer:
type: object
x-java-record: DispatchPlanningModels.DraftContainer
@@ -75,7 +75,7 @@ class DispatchDeliveryArrangementPersistenceTest {
var repository=new DispatchDeliveryRepository(session.getMapper(DispatchDeliveryArrangementMapper.class),session.getMapper(OutboundOrderMapper.class),
session.getMapper(OutboundPalletItemMapper.class),session.getMapper(DeliveryOrderMapper.class),session.getMapper(DeliveryOperationMapper.class),
session.getMapper(LastlegOrderMapper.class),session.getMapper(LastlegOrderStatusHistoryMapper.class));
var numbers=mock(LastlegOrderNoGenerator.class);when(numbers.next(anyString(),anyString(),anyLong())).thenAnswer(a->a.getArgument(0)+"-"+a.getArgument(2));
var numbers=mock(LastlegOrderNoGenerator.class);when(numbers.next(anyString())).thenAnswer(a->a.getArgument(0)+"-"+com.baomidou.mybatisplus.core.toolkit.IdWorker.getId());
var target=new DispatchDeliveryService(execution,repository,grouping,fees,new NormalDeliveryCompletionPolicy(grouping),numbers,json);
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds),new AnnotationTransactionAttributeSource()));
service=(DispatchDeliveryService)proxy.getProxy();
@@ -39,7 +39,7 @@ class DispatchDeliveryServiceTest {
@BeforeEach void prepare() {
context=context(List.of(cargo(1,"101","ONT8",51,2),cargo(2,"101","LAX9",52,3),cargo(3,"102","ONT8",53,4)),false);
var numbers=mock(LastlegOrderNoGenerator.class);
when(numbers.next(anyString(),anyString(),anyLong())).thenAnswer(a->a.getArgument(0)+"-"+a.getArgument(2));
when(numbers.next(anyString())).thenAnswer(a->a.getArgument(0)+"-"+com.baomidou.mybatisplus.core.toolkit.IdWorker.getId());
service=new DispatchDeliveryService(execution,repository,grouping,fees,new NormalDeliveryCompletionPolicy(grouping),numbers,json);
when(execution.lockForArrangement(any(),eq("DP-10"))).thenAnswer(a->context);
when(execution.replay(any(),any(),any())).thenReturn(Optional.empty());
@@ -153,7 +153,7 @@ class DeliveryTransferServiceTest {
ReflectionTestUtils.setField(service, "planAllocations", planAllocations);
ReflectionTestUtils.setField(service, "planLines", planLines);
ReflectionTestUtils.setField(service, "inboundCartons", inboundCartons);
ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator());
ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders());
ReflectionTestUtils.setField(service, "tailOrders", tailOrders);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "tailOrderDetails", tailOrderDetails);
@@ -72,7 +72,7 @@ class ReturnOutboundClosureTest {
ReflectionTestUtils.setField(service, "pdaPicks", picks);
ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications);
ReflectionTestUtils.setField(service, "relabelService", relabelService);
ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator());
ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders());
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
carton = new ReturnInventoryCarton(); carton.setId(10L); carton.setCartonMark("RC#1-1");
carton.setCustomerId("C1"); carton.setOverseasWarehouseId("WH1"); carton.setSourceReturnNo("RT1,RT2");
@@ -82,7 +82,8 @@ class ReturnOutboundPdaPersistenceTest {
stockProxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
ReflectionTestUtils.setField(target, "returnStock", stockProxy.getProxy());
var numbers = mock(LastlegOrderNoGenerator.class);
when(numbers.next(anyString(), eq("WH-1"), anyLong())).thenAnswer(call -> "RETU-" + call.getArgument(2));
var testNumber = new java.util.concurrent.atomic.AtomicInteger();
when(numbers.next(anyString())).thenAnswer(call -> "THCK20261020" + String.format("%04d", testNumber.incrementAndGet()));
ReflectionTestUtils.setField(target, "orderNoGenerator", numbers);
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
@@ -39,7 +39,7 @@ class CargoDiscardOutboundServiceTest {
ReflectionTestUtils.setField(service, "cartons", cartons);
ReflectionTestUtils.setField(service, "profiles", profiles);
ReflectionTestUtils.setField(service, "returnCartons", returnCartons);
ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator());
ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders());
application = new CargoDiscardApplication(); application.setId(1L); application.setCustomerId("C1");
application.setDiscardNo("CD-TEST"); application.setOverseasWarehouseId("99");
application.setTotalPackages(5); application.setStatus("APPROVED");
@@ -66,7 +66,7 @@ class CargoDiscardOutboundServiceTest {
assertEquals("DISCARD_OUTBOUND", document.getOutboundType()); assertEquals("WAIT_PICK", document.getStatus());
assertNull(document.getLxCodes());
assertEquals(5, document.getPackageCount()); assertEquals(2, document.getPalletCount());
assertTrue(service.outboundNo(application).startsWith("OB-"));
assertTrue(service.outboundNo(application).matches("CK[0-9]{12}"));
assertEquals(List.of(3, 2), snapshots.stream().map(OutboundPalletItem::getPackageCount).toList());
verify(outbounds, times(1)).insert(any(OutboundOrder.class)); verify(pallets, times(2)).insert(any(OutboundPalletItem.class));
}
@@ -134,7 +134,7 @@ class CargoDiscardServiceTest {
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "adjustments", adjustments);
ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator());
ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders());
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
ReflectionTestUtils.setField(service, "returnCartons", returnCartons);
@@ -38,7 +38,9 @@ class SmartDispatchOpenApiContractTest {
assertThat(map(paths.get(base+"/drafts"))).containsKey("get");
var candidate=map(map(paths.get(base+"/candidates")).get("get"));
var parameterNames=((List<?>)candidate.get("parameters")).stream().map(SmartDispatchOpenApiContractTest::map).map(p->p.get("name")).toList();
assertThat(parameterNames).contains("warehouseId","destinationWarehouseCode","draftToken").doesNotContain("customerId","fbaWarehouseCode");
assertThat(parameterNames).contains("warehouseId","destinationWarehouseCode","draftToken","page","size","warehouseTab").doesNotContain("customerId","fbaWarehouseCode");
assertThat(map(map(schemas.get("DispatchCandidateList")).get("properties")).keySet())
.containsExactlyInAnyOrder("candidates","snapshotAt","complete","total","page","size","pages");
var draft=map(map(schemas.get("DispatchDraft")).get("properties"));
assertThat(draft.keySet()).containsExactlyInAnyOrder("draftToken","recordVersion","status","warehouseId","containers","sources","expiresAt");
var vehicle=map(map(schemas.get("DispatchDraftVehicle")).get("properties"));assertThat(vehicle.keySet()).containsExactlyInAnyOrder("draftContainerId","dispatch");
@@ -0,0 +1,127 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery;
import org.apache.ibatis.scripting.defaults.DefaultParameterHandler;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.*;
import java.util.*;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*;
/** 显式指定TEST配置才运行;只读真实MySQL,不建表、不造数、不输出客户记录或连接凭据。 */
@EnabledIfSystemProperty(named="dispatch.mysql.profile",matches=".+")
class DispatchCandidateMySqlReadOnlyTest {
private Connection connect() throws Exception {
var profile=new HashMap<String,String>();
for(String line:Files.readAllLines(Path.of(System.getProperty("dispatch.mysql.profile")))) {
var match=java.util.regex.Pattern.compile("^\\s*(?:export\\s+)?([A-Z][A-Z0-9_]*)\\s*=\\s*(.*)$").matcher(line);
if(!match.matches())continue;
String value=match.group(2).trim();
if(value.length()>1&&(value.startsWith("\"")&&value.endsWith("\"")||value.startsWith("'")&&value.endsWith("'")))
value=value.substring(1,value.length()-1);
profile.put(match.group(1),value);
}
assertEquals("test",profile.get("CHONGCHONG_ENV"),"只允许显式TEST只读检查");
String url="jdbc:mysql://"+profile.get("CHONGCHONG_DB_HOST")+":"+profile.get("CHONGCHONG_DB_PORT")
+"/baiyue_lastleg?characterEncoding=UTF-8&connectTimeout=5000&socketTimeout=15000";
try {
var connection=DriverManager.getConnection(url,profile.get("CHONGCHONG_DB_USERNAME"),profile.get("CHONGCHONG_DB_PASSWORD"));
connection.setReadOnly(true);
return connection;
} catch(SQLException error) {
throw new AssertionError("MySQL connection failed; SQLState="+error.getSQLState()+", code="+error.getErrorCode());
}
}
/** 用纯SELECT构造冲突,确认原始UNION拒绝混合collation且实际生产投影保留中文/字符身份,无需修改测试库。 */
@Test void mixedCollationsAreNormalizedBeforeUnionForCountAndPage() throws Exception {
var fields=Arrays.stream(DispatchPalletCandidate.class.getDeclaredFields()).filter(f->f.getType()==String.class)
.map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
var project=DispatchCandidatePageMapper.Sql.class.getDeclaredMethod("project",String.class,Set.class,boolean.class);
project.setAccessible(true);
String general="SELECT "+fields.stream().sorted().map(f->"_utf8mb4'洛杉矶-001' COLLATE utf8mb4_general_ci AS "+f).collect(Collectors.joining(","));
String unicode=general.replace("utf8mb4_general_ci","utf8mb4_unicode_ci");
try(var connection=connect();var statement=connection.createStatement()) {
statement.setQueryTimeout(10);
var failure=assertThrows(SQLException.class,()->statement.executeQuery(general+" UNION ALL "+unicode));
assertTrue(Set.of(1267,1271).contains(failure.getErrorCode()),"MySQL应报告collation冲突");
for(boolean count:List.of(true,false)) {
String sql=project.invoke(null,general,fields,count)+" UNION ALL "+project.invoke(null,unicode,fields,count);
try(var rows=statement.executeQuery(sql)) {
int total=0;
while(rows.next()) {
total++;
assertEquals("洛杉矶-001",rows.getString("overseasWarehouseId"));
assertEquals("洛杉矶-001",rows.getString("fbaWarehouseCode"));
if(!count) {
for(String field:fields)assertEquals("洛杉矶-001",rows.getString(field),field);
assertNull(rows.getObject("planPalletId"));
assertNull(rows.getObject("appointmentAt"));
assertNull(rows.getObject("grossWeightKg"));
}
}
assertEquals(2,total);
}
}
}
}
/** 执行MyBatis正式SQL和绑定参数,分别检查真实四来源count/page以及筛选分支;仅输出行数和SQL耗时。 */
@Test void actualCandidateQueriesExecuteOnMySql() throws Exception {
String warehouse=System.getProperty("dispatch.mysql.warehouse");
assertNotNull(warehouse,"指定只读验收仓");
var configuration=new Configuration();
configuration.addMapper(DispatchCandidatePageMapper.class);
List<CandidateQuery> queries=List.of(
new CandidateQuery(warehouse,null,null,null,null,null,1,20,"ALL"),
new CandidateQuery(warehouse,null,null,null,null,null,2,20,"ALL"),
new CandidateQuery(warehouse,null,null,null,"RELABEL",null,1,20,"ALL"),
new CandidateQuery(warehouse,null,null,null,"RECEIVED",null,1,20,"LAX"),
new CandidateQuery(warehouse,null,null,null,"PLAN",null,1,20,"NYC"),
new CandidateQuery(warehouse,null,null,"ONT8",null,null,1,20,"ALL"),
new CandidateQuery(warehouse,List.of(-1L),null,null,null,null,1,20,"ALL"));
try(var connection=connect()) {
try(var statement=connection.createStatement();var rows=statement.executeQuery("SELECT @@collation_connection,COLLATION(CAST(1 AS CHAR(128)))")) {
assertTrue(rows.next());
System.out.printf("MYSQL_COLLATION connection=%s castAsChar=%s%n",rows.getString(1),rows.getString(2));
}
try(var statement=connection.createStatement();var rows=statement.executeQuery(
"SELECT TABLE_NAME,COLUMN_NAME,COLLATION_NAME FROM information_schema.COLUMNS "
+"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('ll_order','ll_inbound_plan','ll_return_receipt','ll_relabel_application') "
+"AND COLUMN_NAME IN ('customer_id','overseas_warehouse_id','overseas_warehouse_name','overseas_warehouse_code') ORDER BY TABLE_NAME,COLUMN_NAME")) {
while(rows.next())System.out.printf("MYSQL_COLLATION %s.%s=%s%n",rows.getString(1),rows.getString(2),rows.getString(3));
}
for(var query:queries) {
var parameters=new HashMap<String,Object>();
parameters.put("query",query);parameters.put("offset",(long)(query.page()-1)*query.size());
long total=0;int size=0;long started=System.nanoTime();
for(String method:List.of("count","page")) {
var mapped=configuration.getMappedStatement(DispatchCandidatePageMapper.class.getName()+"."+method);
var bound=mapped.getBoundSql(parameters);
assertTrue(bound.getSql().stripLeading().startsWith("SELECT"));
try(var statement=connection.prepareStatement(bound.getSql())) {
statement.setQueryTimeout(10);
new DefaultParameterHandler(mapped,parameters,bound).setParameters(statement);
try(var rows=statement.executeQuery()) {
if(method.equals("count")) { assertTrue(rows.next());total=rows.getLong(1); }
else while(rows.next()) {
size++;assertEquals(warehouse,rows.getString("overseasWarehouseId"));
if(query.destinationWarehouseCode()!=null)assertEquals(query.destinationWarehouseCode(),rows.getString("fbaWarehouseCode"));
}
}
}
}
assertTrue(size<=query.size());assertTrue(size<=total);
System.out.printf(Locale.ROOT,"MYSQL_READ_ONLY page=%d stage=%s tab=%s total=%d rows=%d countAndPageMs=%.3f%n",
query.page(),query.sourceStage(),query.warehouseTab(),total,size,(System.nanoTime()-started)/1_000_000d);
}
}
}
}
@@ -536,6 +536,7 @@ public class DispatchAggregatePersistenceTest {
mock(com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper.class),mock(com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository.class),mock(DispatchAggregateService.class),json);
var vehicles=mock(VehicleRepository.class); var drivers=mock(DriverService.class);var reservations=mock(FleetBookingReservations.class);
var fleetService=new FleetTripService(fleetRepo,vehicles,drivers,warehouses,json,gateway,reservations);
org.springframework.test.util.ReflectionTestUtils.setField(fleetService, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
nativeFleetService=fleetService;
org.springframework.test.util.ReflectionTestUtils.setField(fleetService,"workChanges",mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class));
org.springframework.test.util.ReflectionTestUtils.setField(fleetService,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class));
@@ -22,6 +22,7 @@ class DispatchDraftResourceLockTest {
when(repository.lockTrip("TR-7")).thenReturn(first);when(repository.lockTrip("TR-2")).thenReturn(second);
when(repository.listBindings(7L)).thenReturn(List.of(a));when(repository.listBindings(2L)).thenReturn(List.of(b));
var service=new FleetTripService(repository,vehicles,mock(DriverService.class),mock(FleetWarehouseDirectory.class),new ObjectMapper(),mock(FleetDispatchGateway.class),mock(FleetBookingReservations.class));
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
service.lockExistingDispatchShells(List.of("DS-A","DS-B"));
var order=inOrder(repository,vehicles);
order.verify(repository).lockTrip("TR-2");order.verify(repository).lockTrip("TR-7");
@@ -37,6 +38,7 @@ class DispatchDraftResourceLockTest {
when(vehicles.lockById(anyLong())).thenReturn(Optional.of(mock(VehicleRepository.VehicleSnapshot.class)));
when(repository.lockDriverIdentity(anyLong())).thenAnswer(call->call.getArgument(0));
var service=new FleetTripService(repository,vehicles,mock(DriverService.class),mock(FleetWarehouseDirectory.class),new ObjectMapper(),mock(FleetDispatchGateway.class),mock(FleetBookingReservations.class));
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
var v9=new FleetExecutionModels.DispatchVehicle("MASTER",9L,null,null,null,null,null,List.of());
var v3=new FleetExecutionModels.DispatchVehicle("MASTER",3L,null,null,null,null,null,List.of());
var d8=new FleetExecutionModels.DispatchDriver("MASTER",8L,null,null,null,null,List.of(),null);
@@ -0,0 +1,70 @@
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.port.DispatchCommands;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode;
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository;
import com.baiyue.lastleg.relabel.service.RelabelDispatchSourceService;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 真实排车、车次、命令和来源入口回归;未物化PLAN不得进入仅接受实托的执行锁。 */
class DispatchLoadingEligibilityPersistenceTest {
/** 全车在途和部分实托已拣的混合车均应业务拒绝,原单/命令/占用/扫码事实完全不变。 */
@ParameterizedTest
@ValueSource(booleans={false,true})
void unmaterializedPlanRejectsLoadingBeforePhysicalSourceLock(boolean mixed) throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var plan=f.source(1,"101",501,"ONT8",3);
var selections=mixed?List.of(plan,f.source(2,"202",502,"LAX9",2)):List.of(plan);
var created=f.tx.execute(t->f.service.create(f.actor,f.configured("unreceived-loading",selections)));
var approved=f.approve(created);
f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN' WHERE plan_pallet_id=1");
if(mixed) {
f.picked(2,"202",502,2);
f.db.update("UPDATE ll_dispatch_order SET picked_package_count=2");
}
var providerRows=mock(RelabelDispatchSourceRepository.class);
var provider=spy(new RelabelDispatchSourceService(providerRows,f.json));
var sources=mock(DispatchSourceRepository.class);
var rules=spy(new DispatchService());
ReflectionTestUtils.setField(rules,"relabelSources",provider);
ReflectionTestUtils.setField(rules,"wholeSources",sources);
ReflectionTestUtils.setField(f.service,"sourceRules",rules);
var actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
var preview=f.tx.execute(t->f.service.loading(actor,created.dispatchNo()));
assertThat(preview.pickingReconciled()).isFalse();
assertThat(preview.loadingComplete().allowed()).isFalse();
assertThat(preview.loadingComplete().blockCode()).isEqualTo("PICKING_INCOMPLETE");
assertThat(preview.pallets()).hasSize(mixed?1:0);
var tables=List.of("ll_dispatch_order","ll_dispatch_item","ll_dispatch_command","ll_dispatch_history",
"ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_dispatch_pallet_lock","ll_dispatch_physical_pallet_lock",
"ll_fleet_trip","ll_fleet_trip_history");
var before=snapshot(f,tables);
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"unreceived-negative",preview.manifestHash(),List.of(),"未实收负例");
assertThatThrownBy(()->f.tx.execute(t->f.service.loadingComplete(actor,created.dispatchNo(),request)))
.isInstanceOfSatisfying(BizException.class,error->assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT))
.hasMessage("逐项真实拣货尚未核平");
assertThat(snapshot(f,tables)).isEqualTo(before);
verify(rules,never()).lockExecutionSources(anyString(),anyList());
verifyNoInteractions(provider,providerRows,sources,f.ownedBusinessOwnership);
}
/** 按实际列值读取CLOB及时间,避免比较JDBC临时对象身份。 */
private Object snapshot(DispatchAggregatePersistenceTest.Fixture f,List<String> tables) {
return tables.stream().map(table->f.db.query("SELECT * FROM "+table+" ORDER BY id",(row,index)->{
var values=new java.util.ArrayList<String>();
for(int column=1;column<=row.getMetaData().getColumnCount();column++)values.add(row.getString(column));
return values;
})).toList();
}
}
@@ -28,6 +28,45 @@ import static org.mockito.Mockito.*;
/** 真实提供者→完整组选货→主单/占用→实际库存命令/归属→PDA/装车的本机事务链。 */
class DispatchMergedRelabelPersistenceTest {
/** 原生退货分页按实箱去重计量,不能因申请多明细重复计箱或重量。 */
@Test void nativeReturnPageMeasuresOneCartonOnce() throws Exception {
var f=new Merged(false);var at=LocalDateTime.of(2026,1,1,0,0);
f.insert("ll_return_inventory_carton","id",901,"customer_id","101","overseas_warehouse_id","9",
"carton_mark","NATIVE-CARTON","pallet_code","NATIVE-PALLET","relabel_application_id",201,
"relabel_no","RL-A","status","RELABEL_PENDING","sku_types",1,"total_quantity",2,
"weight",12,"length",100,"width",100,"height",100,"created_at",at,"updated_at",at);
f.f.db.update("UPDATE ll_relabel_item SET source_inventory_position_id=NULL,return_carton_id=901 WHERE id=301");
f.insert("ll_relabel_item","id",303,"application_id",201,"customer_id","101","product_name_zh","同箱另一明细",
"total_package_count",1,"notified_package_count",1,"carton_mark","NATIVE-CARTON","operation_type","CARTON_LABEL",
"return_carton_id",901,"destination_fba_warehouse_code","ONT8");
var query=new DispatchPlanningModels.CandidateQuery("9",null,"RL-A",null,"RELABEL",null,1,20,"ALL");
var mapper=f.f.session.getMapper(DispatchCandidatePageMapper.class);
assertThat(mapper.count(query)).isOne();
var row=mapper.page(query,0).get(0);
assertThat(row.returnCartonSource()).isTrue();assertThat(row.packageCount()).isOne();
assertThat(row.grossWeightKg()).isEqualByComparingTo("12");assertThat(row.volumeCbm()).isEqualByComparingTo("1");
f.f.db.update("UPDATE ll_return_inventory_carton SET weight=NULL,height=NULL WHERE id=901");
row=mapper.page(query,0).get(0);
assertThat(row.grossWeightKg()).isNull();assertThat(row.volumeCbm()).isNull();
}
/** 数据库限页不能截断同托来源组;总数、翻页及服务端搜索必须一致。 */
@Test void candidatePagesRetainCompleteCrossPageSelectionGroup() throws Exception {
var f=new Merged(false);
var all=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,null,null,1,1,"ALL"));
assertThat(all.total()).isEqualTo(2);assertThat(all.rows()).hasSize(1);
var first=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null,1,1,"ALL"));
var second=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null,2,1,"ALL"));
assertThat(first.total()).isEqualTo(2);assertThat(second.total()).isEqualTo(2);
assertThat(first.rows()).hasSize(1);assertThat(second.rows()).hasSize(1);
assertThat(first.rows().get(0).orderRefs()).isNotEqualTo(second.rows().get(0).orderRefs());
var group=f.rules.selectionGroups(first.rows()).values().iterator().next();
assertThat(group.complete()).as(group.blockReasons().toString()).isTrue();
assertThat(group.sources()).extracting(DispatchPlanningModels.SelectionGroupSource::sourceBusinessNo).containsExactlyInAnyOrder("RL-A","RL-B");
var filtered=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,"RL-B","ONT8","RELABEL",null,1,1,"ALL"));
assertThat(filtered.total()).isOne();assertThat(filtered.rows()).extracting(r->r.orderRefs()).containsExactly("RL-B");
var empty=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("99",null,null,null,"RELABEL",null,1,1,"ALL"));
assertThat(empty.total()).isZero();assertThat(empty.rows()).isEmpty();
}
/** 同客户共享position与跨客户共享physical都只占一托,来源及SHIP严格独立。 */
@ParameterizedTest @ValueSource(booleans={false,true})
void mergedApplicationsUseOnePhysicalPalletAndKeepExactSources(boolean crossCustomer) throws Exception {
@@ -190,17 +229,23 @@ class DispatchMergedRelabelPersistenceTest {
for(var table:List.of("ll_order","ll_inventory_position","ll_inventory_balance","ll_inventory_ledger","ll_inventory_operation","ll_physical_pallet_guard",
"ll_relabel_application","ll_relabel_item","ll_relabel_pda_task","ll_relabel_pda_history","ll_relabel_pda_unit_fact","ll_inbound_plan","ll_inbound_plan_pallet",
"ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet",
"ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_order_manual_node","ll_container_shipping_gate")) {
"ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_order_manual_node","ll_container_shipping_gate",
"ll_return_inventory_carton","ll_return_receipt","ll_return_stock_receipt","ll_exception_order","ll_delivery_order")) {
f.db.execute("DROP TABLE IF EXISTS "+table);int start=schema.indexOf("CREATE TABLE "+table+" (");int end=schema.indexOf(") ENGINE=",start);
if(start<0 || end<0)throw new IllegalStateException("正式初始化表缺失:"+table);
// H2的JSON、collation及约束命名空间与MySQL不同;保留实际列和等价四来源排他约束。
f.db.execute(schema.substring(start,end+1).replaceAll("(?i)\\bJSON\\b","CLOB").replaceAll("(?i)\\s+COLLATE\\s+\\w+","")
.replace("_utf8mb4'","'")
.replace("_utf8mb4", "")
.replaceAll("(?i)\\b(UNIQUE KEY|KEY|CONSTRAINT)\\s+(\\w+)","$1 "+table+"_$2")
.replaceAll("\\((receipt_id|return_receipt_id|return_carton_id|dispatch_item_source_id) IS NOT NULL\\)","(CASE WHEN $1 IS NOT NULL THEN 1 ELSE 0 END)"));
}
var provider=new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(f.session.getMapper(RelabelDispatchSourceMapper.class)),f.json);
var origins=new DispatchItemSourceRepository(f.session.getMapper(DispatchItemSourceMapper.class));var snapshots=new DispatchItemSourceService(origins,provider,f.json);
f.session.getConfiguration().addMapper(DispatchCandidatePageMapper.class);
// H2不实现MySQL的字符集转换;这里只验证分页/完整组,collation由真实MySQL只读测试覆盖。
configuration.setDefaultScriptingLanguage(H2CandidateLanguageDriver.class);
configuration.addMapper(DispatchCandidatePageMapper.class);
configuration.setDefaultScriptingLanguage(org.apache.ibatis.scripting.xmltags.XMLLanguageDriver.class);
var sourceRepository=new DispatchSourceRepository(f.session.getMapper(DispatchCandidateMapper.class),f.session.getMapper(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class));
ReflectionTestUtils.setField(sourceRepository,"pages",f.session.getMapper(DispatchCandidatePageMapper.class));
ReflectionTestUtils.setField(rules,"wholeSources",sourceRepository);ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json));
@@ -247,4 +292,11 @@ class DispatchMergedRelabelPersistenceTest {
/** 历史由真实提供者解析,固定发生次序不依赖当前系统时钟。 */
void history(long id,long app,String customer,String action,Object detail,int day) throws Exception {insert("ll_relabel_pda_history","id",id,"task_id",1000+app,"application_id",app,"customer_id",customer,"action",action,"to_status","WAIT_REVIEW","operator_key","worker","operator_name","测试","detail_json",f.json.writeValueAsString(detail),"created_at",LocalDateTime.of(2026,1,day,0,0));}
}
public static class H2CandidateLanguageDriver extends org.apache.ibatis.scripting.xmltags.XMLLanguageDriver {
@Override public org.apache.ibatis.mapping.SqlSource createSqlSource(org.apache.ibatis.session.Configuration configuration,String script,Class<?> parameterType) {
return super.createSqlSource(configuration,script.replaceAll(
"CONVERT\\((s\\.\\w+|NULL) USING utf8mb4\\) COLLATE utf8mb4_unicode_ci","$1"),parameterType);
}
}
}
@@ -111,7 +111,7 @@ class DispatchRepairWorkflowTest {
db.execute("CREATE TABLE IF NOT EXISTS ll_physical_pallet_guard(warehouse_pallet_id BIGINT PRIMARY KEY,created_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3))");
for(var column:List.of("order_id BIGINT","warehouse_pallet_id BIGINT","inventory_position_id BIGINT","received_packages BIGINT"))db.execute("ALTER TABLE ll_inbound_stock_receipt ADD COLUMN IF NOT EXISTS "+column);
candidates=spy(session.getMapper(DispatchCandidateMapper.class));doReturn(List.of()).when(candidates).nativeReturnRelabelAvailable(any(),any(),any());dependencies.put(DispatchCandidateMapper.class,candidates);
dependencies.put(ObjectMapper.class,json);dependencies.put(LastlegOrderNoGenerator.class,new LastlegOrderNoGenerator());
dependencies.put(ObjectMapper.class,json);dependencies.put(LastlegOrderNoGenerator.class,com.baiyue.lastleg.service.TestDocumentNumbers.orders());
var sourceRepository=new com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository(candidates,session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class));
dependencies.put(com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository.class,sourceRepository);dependencies.put(DispatchCommandHasher.class,new DispatchCommandHasher(json));
var relabelSources=new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(mapper(RelabelDispatchSourceMapper.class)),json);
@@ -58,7 +58,7 @@ class ReturnCartonReturnPersistenceTest {
var pallets=mock(PalletPhysicalGateway.class);var warehouse=mock(WarehouseResourceGateway.class);
when(pallets.resolve("P-NEW")).thenReturn(new PalletPhysicalGateway.PalletReference(501,"P-NEW",92,301L,"OCCUPIED","ONT8",1));
when(warehouse.resolveLocation(92,"LOC-301")).thenReturn(new WarehouseResourceGateway.WarehouseLocationReference(301,"LOC-301","RACK",BigDecimal.TEN,"ACTIVE"));
service=new ReturnCartonReturnService(repo,pallets,warehouse,new ObjectMapper(),new com.baiyue.lastleg.service.LastlegOrderNoGenerator());
service=new ReturnCartonReturnService(repo,pallets,warehouse,new ObjectMapper(),com.baiyue.lastleg.service.TestDocumentNumbers.orders());
db.update("INSERT INTO ll_exception_order(id,customer_id,exception_no,delivery_order_id,last_mile_order_id,outbound_no,status,return_direction,overseas_warehouse_id) VALUES(1,'C','EX-1',3,100,'OUT-1','PROCESSING','OVERSEAS_WAREHOUSE','92')");
db.update("INSERT INTO ll_delivery_order(id,customer_id,outbound_order_id) VALUES(3,'C',2)");
db.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,status) VALUES(2,'C','OUT-1',10,'OUTBOUNDED')");
@@ -183,7 +183,7 @@ class ReturnCartonReturnPersistenceTest {
org.springframework.test.util.ReflectionTestUtils.setField(business,"objectMapper",new ObjectMapper().findAndRegisterModules());
org.springframework.test.util.ReflectionTestUtils.setField(business,"evidenceFiles",mock(com.baiyue.lastleg.exceptionorder.gateway.ExceptionEvidenceFileGateway.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"nativeReturns",service);
org.springframework.test.util.ReflectionTestUtils.setField(business,"orderNoGenerator",new com.baiyue.lastleg.service.LastlegOrderNoGenerator());
org.springframework.test.util.ReflectionTestUtils.setField(business,"orderNoGenerator",com.baiyue.lastleg.service.TestDocumentNumbers.orders());
org.springframework.test.util.ReflectionTestUtils.setField(business,"inboundOrders",map(com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"outboundOrders",map(OutboundOrderMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"outboundPalletItems",map(OutboundPalletItemMapper.class));
@@ -22,7 +22,7 @@ class FleetPoolOrderBridgeTest {
LocalLooseboardHistoryMapper looseHistory=mock(LocalLooseboardHistoryMapper.class);
UsTruckOrderMapper trucks=mock(UsTruckOrderMapper.class);
UsTruckOrderStatusHistoryMapper truckHistory=mock(UsTruckOrderStatusHistoryMapper.class);
FleetPoolOrderNumberGenerator numbers=mock(FleetPoolOrderNumberGenerator.class);
com.baiyue.lastleg.service.LastlegDocumentNumberGenerator numbers=mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class);
FleetPoolDeliveryEvidence evidence=mock(FleetPoolDeliveryEvidence.class);
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker.class);
FleetLooseboardDemandRepository demands=mock(FleetLooseboardDemandRepository.class);
@@ -32,7 +32,7 @@ class FleetPoolOrderBridgeTest {
service=new FleetPoolOrderBridge(new FleetPoolOrderRepository(links,loose,looseHistory,trucks,truckHistory),numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources);
org.springframework.test.util.ReflectionTestUtils.setField(service,"demands",demands);
when(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001");
when(numbers.next(anyString())).thenReturn("LOCAL-001");
when(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row);
when(links.lockIncoming("9","WT-7")).thenAnswer(a->row);
when(links.lockDispatch("9","DISPATCH-7")).thenAnswer(a->row==null?List.of():List.of(row));
@@ -190,7 +190,10 @@ class FleetPoolOrderBridgeTest {
assertThat(method.getAnnotation(Transactional.class).propagation()).isEqualTo(Propagation.MANDATORY);
assertThat(Arrays.stream(FleetPoolOrderBridge.class.getDeclaredFields()).map(f->f.getType().getSimpleName()))
.noneMatch(name->name.contains("FleetTrip")||name.contains("FleetBooking")||name.contains("Fee")||name.contains("Inventory"));
assertThat(Arrays.stream(FleetPoolOrderBridge.class.getDeclaredFields()).map(f->f.getType().getPackageName()))
assertThat(Arrays.stream(FleetPoolOrderBridge.class.getDeclaredFields())
// 只允许共享的编号基础能力;业务编排服务仍禁止反向依赖。
.filter(f -> f.getType() != com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class)
.map(f->f.getType().getPackageName()))
.noneMatch(name->name.startsWith("com.baiyue.lastleg.delivery")||name.equals("com.baiyue.lastleg.fleet.trip")
||name.equals("com.baiyue.lastleg.service"));
}
@@ -1,21 +0,0 @@
package com.baiyue.lastleg.fleet.business;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import static org.assertj.core.api.Assertions.*;
class FleetPoolOrderNumberGeneratorTest {
private final FleetPoolOrderNumberGenerator generator=new FleetPoolOrderNumberGenerator();
@Test void existingLooseboardAndTruckFormatLocationAndSuffixArePreserved(){
String day=LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
assertThat(generator.next("LOOSEBOARD","FBA:ONT8",1234567)).isEqualTo("LB-ONT-"+day+"-234567");
assertThat(generator.next("TRUCK","nyc",1000001)).isEqualTo("TR-NYC-"+day+"-000001");
}
@Test void existingPrivateDestinationAndShortCodeRulesArePreserved(){
String day=LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
assertThat(generator.next("TRUCK","private address",9)).isEqualTo("TR-WH-"+day+"-000009");
assertThat(generator.next("LOOSEBOARD","ab12",9)).isEqualTo("LB-AB12-"+day+"-000009");
assertThat(generator.next("TRUCK",null,-1)).isEqualTo("TR-WH-"+day+"-999999");
}
}
@@ -30,6 +30,10 @@ class VehicleCareAuthorizationTest {
}
private void authenticate(String code) { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("user", "unused", List.of(new SimpleGrantedAuthority(code)))); }
@Configuration @EnableMethodSecurity static class Config {
/** 为控制器权限测试中的服务替身补齐编号依赖。 */
@Bean com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers() {
return com.baiyue.lastleg.service.TestDocumentNumbers.documents();
}
@Bean VehicleCareService service() { return mock(VehicleCareService.class); }
@Bean PlatformVehicleCareController controller(VehicleCareService service) { return new PlatformVehicleCareController(service); }
}
@@ -51,6 +51,7 @@ class VehicleCarePersistenceTest {
when(files.upload(anyLong(), anyString(), any())).thenReturn(new VehicleCareModels.File("9001", "photo.png", "image/png", 8));
var target = new VehicleCareService(session.getMapper(VehicleCareMapper.class), session.getMapper(VehicleCareHistoryMapper.class), vehicles, files,
new ObjectMapper().registerModule(new JavaTimeModule()), mock(VehicleCareFinanceService.class));
org.springframework.test.util.ReflectionTestUtils.setField(target, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource()));
service = (VehicleCareService) proxy.getProxy();
@@ -39,7 +39,7 @@ class OwnedBusinessScopePersistenceTest {
baseline.setup();var reader=mock(DispatchDetailReader.class);
when(reader.detail(any(),anyString())).thenAnswer(call->{
var s=baseline.repository.source(call.getArgument(1));var r=baseline.codec.read(s.getResourcesJson(),DispatchContracts.Resources.class);
return new DispatchViews.Detail(s.getDispatchNo(),DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(),
return new DispatchViews.Detail(s.getDispatchNo(),"DELIVERY",DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(),
DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of());
});
queries=tx(new OwnedBusinessQueryService(baseline.repository,baseline.projection,baseline.codec,reader));
@@ -32,6 +32,7 @@ class FleetCapacityOfferServiceTest {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.LIST))));
service=new FleetCapacityOfferService(offers,mock(FleetCapacityOfferCommandMapper.class),trips,mock(FleetBookingMapper.class),mock(FleetBookingService.class),reservations,
execution,mock(FleetCustomerDirectory.class),mock(FleetPoolOrderBridge.class),new ObjectMapper().registerModule(new JavaTimeModule()),Clock.fixed(now,ZoneOffset.UTC));
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
}
@AfterEach void clear(){SecurityContextHolder.clearContext();}
@Test void ordinaryPageOnlyCalculatesRequestedDatabasePageAndPreservesAccurateTotal(){
@@ -112,6 +112,10 @@ class FleetTripAuthorizationTest {
}
@Configuration @EnableMethodSecurity
static class Config {
/** 为控制器权限测试中的服务替身补齐编号依赖。 */
@Bean com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers() {
return com.baiyue.lastleg.service.TestDocumentNumbers.documents();
}
/** 原单需求依赖使用替身,权限测试不访问数据库。 */
@Bean com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService looseboardDemands() { return mock(com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService.class); }
/** 桥接器的原单查询使用仓储替身。 */
@@ -164,6 +164,46 @@ class HttpWarehouseResourceGatewayTest {
server.verify();
}
/** 实际SHIP后报废的原托扫码仍拒绝;保留提供者业务码/原因,不伪装为服务故障或按ID重试。 */
@Test
void inspectPreservesScrappedPalletBusinessConflictWithoutFallback() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/D1747-1-001"))
.andRespond(withSuccess("""
{"code":"A0409","message":"托盘已报废","data":null}
""", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.inspectPallet("D1747-1-001"))
.isInstanceOfSatisfying(ResponseStatusException.class, error -> {
assertThat(error.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
assertThat(error.getReason()).isEqualTo("托盘已报废");
});
server.verify();
}
/** 按ID读取使用同一响应边界,错误码即使带data也不能被当作合法实托。 */
@Test
void idLookupRejectsBusinessConflictEvenWithPayload() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/id/31"))
.andRespond(withSuccess("""
{"code":"A0409","message":"托盘状态冲突","data":{"palletId":31,"palletCode":"PAL-1",
"warehouseId":88,"locationId":40,"status":"IDLE","version":1}}
""", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.resolvePallet(31L))
.isInstanceOfSatisfying(ResponseStatusException.class, error ->
assertThat(error.getStatusCode()).isEqualTo(HttpStatus.CONFLICT));
server.verify();
}
/** 同一个状态冲突无论通过HTTP状态还是业务信封传输,都不能降为400或503。 */
@Test
void inspectPreservesHttpConflict() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-CONFLICT"))
.andRespond(withStatus(HttpStatus.CONFLICT));
assertThatThrownBy(() -> gateway.inspectPallet("PAL-CONFLICT"))
.isInstanceOfSatisfying(ResponseStatusException.class, error ->
assertThat(error.getStatusCode()).isEqualTo(HttpStatus.CONFLICT));
server.verify();
}
@Test
void resolveReservedFallsBackToConfirmedPhysicalBindingForEarlierPlanDisplayCode() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/IP-ACY2-20260909-166786-P001"))
@@ -359,7 +359,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
new ContainerInboundPlanPreviewResponse.Group("ONE_CLICK_DROPSHIP", "31", "US-LAX-01", "Los Angeles",
1, 1, 1, 1, 0, 0, 1, 0, List.of(overseas))), null);
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class)))
when(numbers.next(eq("INBOUND_PLAN")))
.thenReturn("IP-FBA", "IP-OVERSEAS");
when(plans.sourcePalletNos("100", 11L, "ONE_CLICK_DROPSHIP")).thenReturn(List.of("MSCU6639870-ABE3-001"));
when(plans.sourcePalletNos("200", 12L, "ONE_CLICK_DROPSHIP")).thenReturn(List.of("MSCU6639870-US-LAX-01-001"));
@@ -392,7 +392,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
List.of(new ContainerInboundPlanPreviewResponse.Group("FIRST_LEG", "31", "US-LAX-01", "Los Angeles",
1, 1, 2, 0, 2, 0, 2, 0, List.of(overseas))), null);
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-OVERSEAS");
when(numbers.next(eq("INBOUND_PLAN"))).thenReturn("IP-OVERSEAS");
when(plans.sourcePalletNos("200", 12L, "FIRST_LEG")).thenReturn(List.of());
ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand",
@@ -424,7 +424,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2",
1, 1, 3, 2, 1, 0, 3, 0, List.of(order))), null);
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-ACY2");
when(numbers.next(eq("INBOUND_PLAN"))).thenReturn("IP-ACY2");
when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of(
"D0004-101-001", "D0004-101-002"));
@@ -475,7 +475,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
assertEquals(21L, preparation.items().get(0).planId());
assertEquals("IP-MANUAL", preparation.items().get(0).planNo());
verify(numbers, never()).next(any(String.class), any(String.class), any(Long.class));
verify(numbers, never()).next(any(String.class));
}
private ContainerInboundPlanPreviewResponse.Order previewOrder(String customerId, String orderId,
@@ -262,6 +262,7 @@ class FbaAppointmentPersistenceTest {
@Bean com.baiyue.lastleg.mapper.LastlegOrderMapper orders(){var mock=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);var row=new com.baiyue.lastleg.entity.LastlegOrder();row.setId(100L);row.setCustomerId("C");row.setInboundCode("WT-REAL");when(mock.selectById(100L)).thenReturn(row);return mock;}
@Bean AppointmentExternalSourceGateway bookings(){var mock=mock(AppointmentExternalSourceGateway.class);when(mock.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("C","B-1","TR-1","RECEIVED","100","FBA:ONT8",100L,10,2L)));return mock;}
@Bean AppointmentSourceLinker linker(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings){return new InboundAppointmentSourceLinker(orders,items,bookings);}
@Bean com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers() { return com.baiyue.lastleg.service.TestDocumentNumbers.documents(); }
@Bean InboundFbaAppointmentService service(){return new InboundFbaAppointmentService();}
@Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper workMapper(SqlSessionTemplate session){return session.getMapper(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.class);}
/** 使用生产持久层,继续验证需求失效与预约写入的同事务行为。 */
@@ -33,6 +33,7 @@ class InboundFbaAppointmentServiceTest {
headers=mock(InboundFbaAppointmentMapper.class);items=mock(InboundFbaAppointmentItemMapper.class);query=mock(InboundFbaAppointmentQueryMapper.class);
commands=mock(FbaAppointmentCommandMapper.class);sources=mock(AppointmentSourceService.class);flags=mock(AppointmentBatchPreviewMapper.class);
service=new InboundFbaAppointmentService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
ReflectionTestUtils.setField(service,"receiving",mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class));
ReflectionTestUtils.setField(service,"receivingWork",mock(com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.class));
ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges.class));
@@ -95,7 +96,7 @@ class InboundFbaAppointmentServiceTest {
}
@Test void savesCrossCustomerArrangementBeforeAnyInboundPlanOrExternalNumberExists() {
var result=create("create");assertTrue(result.appointmentNo().startsWith("AP-"));assertNull(result.confirmationNo());assertNull(result.appointmentAt());
var result=create("create");assertTrue(result.appointmentNo().matches("YY[0-9]{12}"));assertNull(result.confirmationNo());assertNull(result.appointmentAt());
assertEquals(LocalDate.of(2026,10,15),result.plannedDate());assertEquals(2,result.selectedCount());assertEquals("PENDING",result.appointmentStatus());
assertEquals(List.of("A","B"),result.items().stream().map(FbaAppointmentResponse.Item::customerId).toList());verify(query,never()).selectedLines(anyList());
}
@@ -75,7 +75,7 @@ class InboundPlanServiceTest {
ReflectionTestUtils.setField(service, "commandMapper", commandMapper);
ReflectionTestUtils.setField(service, "sourcePalletMapper", sourcePalletMapper);
ReflectionTestUtils.setField(service, "objectMapper", JsonMapper.builder().findAndAddModules().build());
ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator());
ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders());
org.mockito.Mockito.lenient().when(lineMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(palletMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(allocationMapper.selectList(any())).thenReturn(List.of());
@@ -47,7 +47,7 @@ class LocalLooseboardServiceTest {
@Mock LocalLooseboardOperationMapper operations;
@Mock LastlegOrderMapper lastlegOrders;
@Mock LastlegOrderStatusHistoryMapper lastlegHistories;
@Spy LastlegOrderNoGenerator lastlegOrderNoGenerator = new LastlegOrderNoGenerator();
@Spy LastlegOrderNoGenerator lastlegOrderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders();
private LocalLooseboardService service;
private final Map<String, LocalLooseboardOperation> operationRows = new HashMap<>();
@@ -67,8 +67,7 @@ class LocalLooseboardServiceTest {
// 旧独立履约用例保留真实关联尾程,新增需求不创建该来源。
var legacyTail = new LastlegOrder(); legacyTail.setId(99L); legacyTail.setCustomerId("100");
org.mockito.Mockito.lenient().when(lastlegOrders.selectById(99L)).thenReturn(legacyTail);
org.mockito.Mockito.lenient().when(lastlegOrderNoGenerator.next(anyString(), any(), anyLong()))
.thenReturn("LB-WH-20260901-000001");
org.mockito.Mockito.lenient().doReturn("LB202610200001").when(lastlegOrderNoGenerator).next(anyString());
org.mockito.Mockito.lenient().when(operations.findKey(anyString(), any(), anyString()))
.thenAnswer(call -> operationRows.get(call.getArgument(2)));
org.mockito.Mockito.lenient().when(operations.insert(any(LocalLooseboardOperation.class))).thenAnswer(call -> {
@@ -27,6 +27,10 @@ class ProcurementLifecycleAuthorizationTest {
private final ProcurementLifecycleService service=context.getBean(ProcurementLifecycleService.class);
@Configuration @EnableMethodSecurity(proxyTargetClass=true)
static class Config {
/** 为控制器权限测试中的服务替身补齐编号依赖。 */
@Bean com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers() {
return com.baiyue.lastleg.service.TestDocumentNumbers.documents();
}
@Bean ProcurementLifecycleService service(){return mock(ProcurementLifecycleService.class);}
@Bean ProcurementLifecycleController controller(ProcurementLifecycleService service){return new ProcurementLifecycleController(service);}
@Bean ProcurementPhysicalService physicalService(){return mock(ProcurementPhysicalService.class);}
@@ -43,7 +43,7 @@ class OverseasProcurementServiceTest {
@Mock OverseasProcurementOrderMapper orders;
@Mock OverseasProcurementStatusHistoryMapper histories;
@Mock ProcurementWarehouseDirectory warehouseDirectory;
@Mock ProcurementOrderNumberGenerator orderNoGenerator;
@Mock com.baiyue.lastleg.service.LastlegDocumentNumberGenerator orderNoGenerator;
@Mock OverseasProcurementFileMapper procurementFiles;
@Mock OverseasProcurementFeeMapper procurementFees;
@Mock ProcurementFileGateway fileGateway;
@@ -62,7 +62,7 @@ class OverseasProcurementServiceTest {
ReflectionTestUtils.setField(service, "procurementFiles", procurementFiles);
ReflectionTestUtils.setField(service, "procurementFees", procurementFees);
ReflectionTestUtils.setField(service, "fileGateway", fileGateway);
lenient().when(orderNoGenerator.next(anyString(), any())).thenReturn("ACME202609050001");
lenient().when(orderNoGenerator.next(anyString())).thenReturn("ACME202609050001");
lenient().when(procurementFiles.selectList(any())).thenReturn(List.of());
lenient().when(procurementFees.selectList(any())).thenReturn(List.of());
}
@@ -58,8 +58,11 @@ class ProcurementChargePersistenceTest {
when(commands.publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong())).thenAnswer(i->"cmd-"+events.incrementAndGet());
when(commands.publishRefund(anyLong(),anyString(),anyString(),any(),anyString(),anyString(),anyLong())).thenAnswer(i->"cmd-"+events.incrementAndGet());
var json=new ObjectMapper().findAndRegisterModules();finance=new ProcurementFinanceService(fees,files,orders,converter,sources,commands,jdbc,json);
org.springframework.test.util.ReflectionTestUtils.setField(finance, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
org.springframework.test.util.ReflectionTestUtils.setField(finance, "feeNumbers", new com.baiyue.lastleg.procurement.repository.ProcurementFeeNumberRepository(fees));
lifecycle=new ProcurementLifecycleService(orders,files,mock(ProcurementWarehouseDirectory.class),finance,converter,jdbc,json);
charges=new ProcurementChargeService(lifecycle,finance,jdbc);adjustments=new ProcurementAdjustmentService(lifecycle,finance,jdbc);
org.springframework.test.util.ReflectionTestUtils.setField(adjustments, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT","货款","ZERO",BigDecimal.ZERO,"CNY",LocalDate.of(2026,9,1),null,"free")));
estimateId=tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE","预估","ZERO",BigDecimal.ZERO,"CNY",LocalDate.of(2026,9,1),null,"zero estimate"))).getId();
}
@@ -51,6 +51,7 @@ class ProcurementControlPersistenceTest {
when(warehouses.required(92)).thenReturn(new ReceivingWarehouse(92L,"NEW","Correct receiving warehouse","US","ACTIVE"));
var json=new ObjectMapper().findAndRegisterModules();lifecycle=new ProcurementLifecycleService(orders,files,warehouses,finance,mock(OverseasFeeCurrencyConverter.class),jdbc,json);
service=new ProcurementControlService(lifecycle,finance,warehouses,jdbc,json);
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
}
@Test void purchasedButUnreceivedGoodsCanCloseOnlyWithAllSupplierDispositionAndCustomerEvidence() {
assertThat(service.read("9","PO-1").remaining()).containsExactly(new ProcurementControlService.Remaining("PURCHASE_REMAINDER",null,"PO-1",10));
@@ -68,6 +68,8 @@ class ProcurementFinancePersistenceTest {
when(commands.publishRefund(anyLong(),anyString(),anyString(),any(),anyString(),anyString(),anyLong()))
.thenAnswer(i -> "command-"+sequence.incrementAndGet());
service=new ProcurementFinanceService(fees,files,orders,converter,sources,commands,jdbc,new ObjectMapper().findAndRegisterModules());
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
org.springframework.test.util.ReflectionTestUtils.setField(service, "feeNumbers", new com.baiyue.lastleg.procurement.repository.ProcurementFeeNumberRepository(fees));
}
@Test void actualFeePublishesOnlyAfterSuccessfulConsumption() {
@@ -235,7 +237,13 @@ class ProcurementFinancePersistenceTest {
handle(result(fees.selectById(fee.getId()),"SETTLE","SUCCEEDED","SETTLED","100"));
jdbc.update("INSERT INTO ll_event_outbox VALUES('OVERSEAS_PROCUREMENT_FEE','901','PUBLISHED')");
var refund=tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","refund-key"));
assertThat(tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","refund-key")).getId()).isEqualTo(refund.getId());
assertThat(refund.getFeeNo()).matches("PF[0-9]{12}");
var replay = tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","refund-key"));
assertThat(replay.getId()).isEqualTo(refund.getId());
assertThat(replay.getFeeNo()).isEqualTo(refund.getFeeNo());
assertThatThrownBy(() -> tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("21"),1,"correction","refund-key")))
.hasMessageContaining("退款幂等键对应不同");
verify(commands, times(1)).publishRefund(anyLong(),anyString(),anyString(),any(),anyString(),anyString(),anyLong());
assertThatThrownBy(() -> tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","another-key")))
.hasMessageContaining("未完成的调整或退款");
handle(new WalletCommandCompletedV1(refund.getWalletCommandEventId(),"REFUND",9,"PROCUREMENT_FEE",refund.getFeeNo(),
@@ -1,17 +0,0 @@
package com.baiyue.lastleg.procurement.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ProcurementOrderNumberGeneratorTest {
@Test void convertsChineseCompanyNameToInitials() {
assertThat(ProcurementOrderNumberGenerator.initials("杭州积乐贸易有限公司")).isEqualTo("HZJLMYYX");
}
@Test void keepsOneInitialPerLatinWordAndUsesFallback() {
assertThat(ProcurementOrderNumberGenerator.initials("Great Ocean Trading LLC")).isEqualTo("GOTL");
assertThat(ProcurementOrderNumberGenerator.initials(" ")).isEqualTo("KH");
}
@Test void limitsInitialsToEightCharacters() {
assertThat(ProcurementOrderNumberGenerator.initials("一二三四五六七八九十")).hasSize(8);
}
}
@@ -55,6 +55,7 @@ class ProcurementPhysicalPersistenceTest {
when(finance.current(11,ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT")).thenReturn(payment);when(finance.sourceLinked(payment)).thenReturn(true);
var lifecycle=new ProcurementLifecycleService(orders,files,warehouses,finance,mock(OverseasFeeCurrencyConverter.class),jdbc,new ObjectMapper().findAndRegisterModules());
service=new ProcurementPhysicalService(lifecycle,warehouses,finance,jdbc);
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
}
@Test void splitReceiptsAndCartonSourcesRemainConservedWithoutWms() {
@@ -24,7 +24,9 @@ class ProcurementQueryServiceTest {
var m=Pattern.compile("CREATE TABLE IF NOT EXISTS "+name+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema);assertThat(m.find()).isTrue();jdbc.execute(m.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");"));
}
var physical=new ProcurementPhysicalService(fixture.lifecycle,fixture.warehouses,fixture.finance,jdbc);
org.springframework.test.util.ReflectionTestUtils.setField(physical, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
transport=new ProcurementTransportService(fixture.lifecycle,physical,fixture.finance,fixture.warehouses,jdbc,json);
org.springframework.test.util.ReflectionTestUtils.setField(transport, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
base=mock(OverseasProcurementService.class);
when(base.detail("9","PO-1")).thenReturn(new OverseasProcurementResponses.Detail(null,null,null,null,null,null,"internal remark","operator","reviewer","internal review",null,List.of(),List.of(new OverseasProcurementResponses.FileInfo(1L,"EXCEPTION",1001L,"private.pdf","application/pdf",10,null)),List.of()));
query=new ProcurementQueryService(fixture.lifecycle,physical,transport,fixture.finance,mock(ProcurementAdjustmentService.class),fixture.service,base,jdbc);
@@ -68,7 +68,9 @@ class ProcurementTransportPersistenceTest {
var json=new ObjectMapper().findAndRegisterModules();
var lifecycle=new ProcurementLifecycleService(orders,files,warehouses,finance,mock(OverseasFeeCurrencyConverter.class),jdbc,json);
var physical=new ProcurementPhysicalService(lifecycle,warehouses,finance,jdbc);
org.springframework.test.util.ReflectionTestUtils.setField(physical, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
service=new ProcurementTransportService(lifecycle,physical,finance,warehouses,jdbc,json);
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
}
@Test void entireBatchShipsOnceWithFrozenAddressLabelsAndSources() {
prepare();var request=shipRequest(Leg.OVERSEAS,"ship");
@@ -208,17 +208,17 @@ class RelabelApplicationServiceTest {
@Mock OverseasFeeSourceService feeSources;
@Mock ReturnOrderMapper returnOrders;
@Mock com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper pdaTasks;
@Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator();
@Spy LastlegOrderNoGenerator orderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders();
private RelabelApplicationService service;
private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers;
@BeforeEach
void setUp() {
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.CompactBusinessNumberService.class);
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class);
service = new RelabelApplicationService();
ReflectionTestUtils.setField(service, "compactNumbers", compactNumbers);
org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030");
org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030");
ReflectionTestUtils.setField(service, "bindingGuard", bindingGuard);
ReflectionTestUtils.setField(service, "returnPackingTraces", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.class));
ReflectionTestUtils.setField(service, "skuConversions", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.ReturnSkuConversionService.class));
@@ -108,7 +108,7 @@ class ReturnRelabelDeliveryTest {
when(customers.find("C1")).thenReturn(new FleetCustomerDirectory.Customer("C1", "C", "测试客户", null, null));
when(warehouses.required(100L, "OVERSEAS")).thenReturn(new DriverModels.WarehouseOption("100", "WH", "洛杉矶测试仓", "OVERSEAS"));
ReflectionTestUtils.setField(service, "outboundTasks", mock(OutboundPdaTaskMapper.class));
ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator());
ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders());
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
var application = new RelabelApplication(); application.setId(1L); application.setCustomerId("C1");
application.setRelabelNo("REL1"); application.setOverseasWarehouseId("100"); application.setDeliveryMethod(deliveryMethod);
@@ -0,0 +1,76 @@
package com.baiyue.lastleg.repository;
import com.baiyue.lastleg.mapper.DocumentSequenceMapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.*;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
/** 实际 Mapper 和隔离 H2 MySQL 模式验证原子序列,不访问部署环境。 */
class DocumentSequenceRepositoryTest {
private static class Fixture {
final JdbcTemplate jdbc;
final DataSourceTransactionManager transactions;
final DocumentSequenceMapper mapper;
final DocumentSequenceRepository repository;
/** 仅测试造表允许 JDBC;被测路径全部使用 Mapper。 */
Fixture() throws Exception {
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa","");
jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data);
jdbc.execute("CREATE TABLE ll_document_sequence(sequence_key VARCHAR(32) PRIMARY KEY,current_value BIGINT NOT NULL)");
var configuration=new MybatisConfiguration();configuration.addMapper(DocumentSequenceMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet();
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DocumentSequenceMapper.class);
var proxy=new ProxyFactory(new DocumentSequenceRepository(mapper));proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource()));
repository=(DocumentSequenceRepository)proxy.getProxy();
}
}
/** 日期和前缀各自独立,重复分配持续递增。 */
@Test void dateAndPrefixHaveIndependentSequences() throws Exception {
var f=new Fixture();
assertThat(f.repository.next("AA:20261020")).isEqualTo(1);
assertThat(f.repository.next("AA:20261020")).isEqualTo(2);
assertThat(f.repository.next("BB:20261020")).isEqualTo(1);
assertThat(f.repository.next("AA:20261021")).isEqualTo(1);
}
/** 两个线程使用真实事务连接并发争用,不能产生重号。 */
@Test void concurrentConnectionsAllocateUniqueNumbers() throws Exception {
var f=new Fixture();var pool=Executors.newFixedThreadPool(2);
try {
var work=new ArrayList<Callable<Long>>();
for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020"));
var values=new TreeSet<Long>();
for(var future:pool.invokeAll(work))values.add(future.get());
assertThat(values).hasSize(64);assertThat(values.first()).isEqualTo(1L);assertThat(values.last()).isEqualTo(64L);
} finally {pool.shutdownNow();}
}
/** 外层建单失败后不能把已经分配的单号再交给其他单据。 */
@Test void businessRollbackDoesNotReuseAllocatedNumber() throws Exception {
var f=new Fixture();
assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{
assertThat(f.repository.next("AA:20261020")).isEqualTo(1);
throw new IllegalStateException("业务回滚");
})).isInstanceOf(IllegalStateException.class);
assertThat(f.repository.next("AA:20261020")).isEqualTo(2);
}
/** 同一会话中连接值变化后,读取必须绕过 MyBatis 缓存。 */
@Test void connectionResultIsNeverCached() throws Exception {
var f=new Fixture();
new TransactionTemplate(f.transactions).executeWithoutResult(status->{
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class);
assertThat(f.mapper.allocatedValue()).isEqualTo(7L);
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class);
assertThat(f.mapper.allocatedValue()).isEqualTo(37L);
});
}
}
@@ -281,7 +281,7 @@ class ReturnInventoryServiceTest {
receipt.setPalletCode("PAL-RETURN-1");
receipt.setUnitType("PALLET");
receipt.setUnitCode("PAL-RETURN-1");
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-1");
service.createReceiptCarton(order, receipt, null,
List.of(new ReturnInventoryService.ReceiptLine("SKU-A", "商品A", 2)));
@@ -339,7 +339,7 @@ class ReturnInventoryServiceTest {
when(cartons.listReceipt(21L)).thenReturn(List.of(source));
when(cartons.lockMark("RC-SOURCE")).thenReturn(source);
when(cartonItems.lockCarton(31L)).thenReturn(List.of(skuA, skuB));
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-MERGED");
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
service.merge(new PdaWarehouseIdentity("operator-1", "操作员", 42L),
@@ -382,13 +382,16 @@ class ReturnInventoryServiceTest {
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 6L)));
when(packingTrace.ownership("10001", "RC-SOURCE", "SKU-A")).thenReturn(List.of(
new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership("RT-1", 3L)));
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET");
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
service.merge(new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(quantity));
var target = ArgumentCaptor.forClass(ReturnInventoryCarton.class);
verify(cartons).insert(target.capture());
assertThat(target.getValue().getCartonMark()).isEqualTo(LastlegOrderNoGenerator.compactCartonMark(target.getValue().getId()));
verify(packingTrace).insert(anyLong(), eq("10001"), anyLong(), eq("RC-SOURCE"),
eq("RC-TARGET"), eq("RT-1"), eq("SKU-A"), eq(quantity), any());
eq(target.getValue().getCartonMark()), eq("RT-1"), eq("SKU-A"), eq(quantity), any());
verify(packingTrace, org.mockito.Mockito.times(1)).insert(anyLong(), anyString(), anyLong(),
anyString(), anyString(), anyString(), anyString(), anyLong(), any());
assertThat(source.getTotalQuantity()).isEqualTo(6L - quantity);
@@ -432,7 +435,7 @@ class ReturnInventoryServiceTest {
void fullyConsumedParcelSourceIsMergedAndCreatesOnePhysicalCarton() {
ReturnInventoryCarton locked = prepareConcurrentSource();
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET");
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
service.merge(new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(2));
assertThat(locked.getTotalQuantity()).isZero();
@@ -457,14 +460,14 @@ class ReturnInventoryServiceTest {
var source = prepareConcurrentSource();
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
when(cartons.completedSkuQuantity(31L, "SKU-A")).thenReturn(2L);
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET");
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
var relabel = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.mapper.RelabelItemMapper.class);
ReflectionTestUtils.setField(service, "relabelItems", relabel);
service.merge(new PdaWarehouseIdentity("operator-2", "name", 42L), mergeRequest(2));
var target = ArgumentCaptor.forClass(ReturnInventoryCarton.class);
verify(cartons).insert(target.capture());
verify(relabel).moveCompletedReturnCarton("10001", 31L, target.getValue().getId(), "RC-TARGET");
verify(relabel).moveCompletedReturnCarton("10001", 31L, target.getValue().getId(), target.getValue().getCartonMark());
assertThat(source.getTotalQuantity()).isZero();
}
@@ -579,7 +582,7 @@ class ReturnInventoryServiceTest {
when(cartonItems.lockSku(31L, "SKU-A")).thenReturn(line);
when(balances.reserveAvailable("10001", "42", "SKU-A", 2)).thenReturn(1);
when(balances.releaseAvailable("10001", "42", "SKU-A", 2)).thenReturn(1);
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("PLAN-1");
when(numbers.next(anyString())).thenReturn("PLAN-1");
var request = new ReturnInventoryRequests.PackingPlanCreate(List.of("RT-1"), "SKU",
List.of(new ReturnInventoryRequests.PlanBox(List.of(new ReturnInventoryRequests.PlanItem("SKU-A", 2)), null, null, null, null)), null, "plan-key");
var created = service.createPackingPlan("platform", request);
@@ -131,13 +131,13 @@ class ReturnReceiptServiceTest {
ReturnReceiptService service;
PdaWarehouseIdentity identity;
private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers;
@BeforeEach
void setUp() {
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.CompactBusinessNumberService.class);
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class);
service = new ReturnReceiptService();
ReflectionTestUtils.setField(service, "compactNumbers", compactNumbers);
org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030");
org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "202610200001");
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderItems", orderItems);
ReflectionTestUtils.setField(service, "receipts", receipts);
@@ -257,7 +257,7 @@ class ReturnReceiptServiceTest {
when(orderItems.lockOrder(1L)).thenReturn(List.of(item(11L, "SKU-A", 50, 50), item(12L, "SKU-B", 40, 40)));
when(fbaWarehouseValidator.required("200", "XLX6", null)).thenReturn(
new FbaWarehouseValidator.VerifiedFbaWarehouse(200L, "XLX6", "FBA XLX6", "US", "CA"));
when(compactNumbers.next(eq("TT"), eq("customer-1"))).thenReturn("RL-MULTI");
when(compactNumbers.next(eq("THHB"))).thenReturn("RL-MULTI");
when(lastlegOrders.selectById(99L)).thenReturn(tail);
when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(first, second));
when(inventory.matchesReturnSource(any(), eq("RT-MULTI"))).thenCallRealMethod();
@@ -310,7 +310,7 @@ class ReturnReceiptServiceTest {
when(orderItems.lockOrder(1L)).thenReturn(List.of(item(11L, "SKU-A", 100, 100)));
when(fbaWarehouseValidator.required("200", "XLX6", null)).thenReturn(
new FbaWarehouseValidator.VerifiedFbaWarehouse(200L, "XLX6", "FBA XLX6", "US", "CA"));
when(compactNumbers.next(eq("TT"), eq("customer-1"))).thenReturn("RL-SECOND");
when(compactNumbers.next(eq("THHB"))).thenReturn("RL-SECOND");
when(lastlegOrders.selectById(99L)).thenReturn(tail);
when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(carton));
when(inventory.matchesReturnSource(any(), eq("RT-MULTI"))).thenCallRealMethod();
@@ -365,7 +365,7 @@ class ReturnReceiptServiceTest {
void createForecastPersistsCompleteTailOrderSourceAndReturnsItsBusinessNumber() {
AtomicReference<ReturnOrder> saved = new AtomicReference<>();
when(orders.findKey("customer-1", "create-key")).thenReturn(null);
when(compactNumbers.next(eq("TT"), eq("customer-1"))).thenReturn("RT-1");
when(compactNumbers.next(eq("TH"))).thenReturn("RT-1");
when(orders.insert(any(ReturnOrder.class))).thenAnswer(invocation -> { saved.set(invocation.getArgument(0)); return 1; });
when(orders.findNo("customer-1", "RT-1")).thenAnswer(invocation -> saved.get());
when(orderItems.listOrder(anyLong())).thenReturn(List.of());
@@ -389,7 +389,7 @@ class ReturnReceiptServiceTest {
assertEquals("商品A", cargo.getValue().getProductNameZh());
assertEquals(2, cargo.getValue().getProductQuantity());
assertEquals(0, cargo.getValue().getPackageCount());
verify(numbers, never()).next(eq("RETURN_RELABEL"), anyString(), anyLong());
verify(numbers, never()).next(eq("RETURN_RELABEL"));
}
@Test
@@ -559,8 +559,8 @@ class ReturnReceiptServiceTest {
when(orders.lockNo("RT-1")).thenReturn(order);
when(orderItems.lockOrder(1L)).thenReturn(items);
when(orderItems.listOrder(1L)).thenReturn(items);
when(numbers.next(eq("RETURN_RECEIPT"), eq("10"), anyLong())).thenReturn("RR-1");
when(numbers.next(eq("RETURN_BATCH"), eq("10"), anyLong())).thenReturn("RB-1");
when(numbers.next(eq("RETURN_RECEIPT"))).thenReturn("RR-1");
when(numbers.next(eq("RETURN_BATCH"))).thenReturn("RB-1");
ReturnInventoryCarton carton = new ReturnInventoryCarton();
carton.setCartonMark(cartonMark);
when(inventory.createReceiptCarton(eq(order), any(ReturnReceipt.class), any(), any())).thenReturn(carton);
@@ -344,6 +344,60 @@ class DashboardSqlSemanticTest {
assertTrue(rendered.endsWith(finalSelect));rendered=rendered.substring(0,rendered.length()-finalSelect.length())
+"SELECT customer_code,sku_code,bucket_code FROM classified WHERE bucket_code IS NOT NULL";
}
String id="dashboard.literal."+(++sequence);config.addMappedStatement(new MappedStatement.Builder(config,id,new org.apache.ibatis.builder.StaticSqlSource(config,rendered),SqlCommandType.SELECT).resultMaps(original.getResultMaps()).build());return session.selectList(id);
// H2 会指数展开重复引用的 CTE;沿用周转测试的逐关系物化方式,
// 保留生产查询表达式和结果映射,不据此推定 MySQL 的执行计划。
var relations = new ArrayList<String>();
try {
String finalSql = materializeRelations(rendered, relations);
String id="dashboard.literal."+(++sequence);
config.addMappedStatement(new MappedStatement.Builder(config,id,
new org.apache.ibatis.builder.StaticSqlSource(config,finalSql),SqlCommandType.SELECT)
.resultMaps(original.getResultMaps()).build());
return session.selectList(id);
} catch (SQLException error) {
throw new AssertionError("本地统计 SQL 测试关系物化失败", error);
} finally {
Collections.reverse(relations);
for (String name : relations) {
try (var statement = session.getConnection().createStatement()) {
statement.execute("DROP TABLE " + name);
} catch (SQLException error) {
throw new AssertionError("本地统计 SQL 测试关系释放失败:" + name, error);
}
}
}
}
/** 只物化实际生产 CTE 的查询内容,临时表局限在当前隔离 H2 会话。 */
private String materializeRelations(String sql, List<String> relations) throws SQLException {
if (!sql.startsWith("WITH ")) return sql;
int cursor = 5;
while (true) {
int as = sql.indexOf(" AS (", cursor);
assertTrue(as >= cursor, "生产 CTE 应使用具名 AS 查询");
String name = sql.substring(cursor, as).trim();
assertTrue(name.matches("[A-Za-z_][A-Za-z0-9_]*"), "测试临时关系必须是固定 SQL 标识");
int start = as + 5, end = start, depth = 1;
boolean quoted = false;
while (depth > 0) {
char ch = sql.charAt(end++);
if (ch == '\'') {
if (quoted && end < sql.length() && sql.charAt(end) == '\'') { end++; continue; }
quoted = !quoted;
} else if (!quoted) {
if (ch == '(') depth++;
if (ch == ')') depth--;
}
}
try (var statement = session.getConnection().createStatement()) {
statement.execute("CREATE LOCAL TEMPORARY TABLE " + name + " AS " + sql.substring(start, end - 1));
}
relations.add(name);
cursor = end;
while (cursor < sql.length() && Character.isWhitespace(sql.charAt(cursor))) cursor++;
if (sql.charAt(cursor) != ',') return sql.substring(cursor);
cursor++;
while (cursor < sql.length() && Character.isWhitespace(sql.charAt(cursor))) cursor++;
}
}
}
@@ -1,41 +0,0 @@
package com.baiyue.lastleg.service;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class CompactBusinessNumberServiceTest {
@Test void usesCustomerInitialsAndShanghaiSecondsWithoutSequenceSuffix() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
var customers = mock(ReturnOrderReferenceResolver.class);
when(customers.customerName("42")).thenReturn("柏悦物流");
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class))
.thenAnswer(call -> Instant.now().getEpochSecond());
String number = new CompactBusinessNumberService(jdbc, customers).next("TB", "42");
assertTrue(number.matches("TB-BYWL-[0-9]{12}"), number);
assertEquals("AB", CompactBusinessNumberService.initials("Alpha Beta"));
}
@Test void usesDatabaseLogicalSecondAcrossInstancesAndClockRollback() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
var first = new CompactBusinessNumberService(jdbc, null);
var second = new CompactBusinessNumberService(jdbc, null);
Instant now = Instant.parse("2026-10-16T02:30:00Z");
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class))
.thenReturn(now.getEpochSecond(), now.getEpochSecond() + 1, now.getEpochSecond() + 2);
assertEquals("ZY-ABC-261016103000", first.allocate("ZY", "ABC", now));
assertEquals("ZY-ABC-261016103001", second.allocate("ZY", "ABC", now));
assertEquals("ZY-ABC-261016103002", first.allocate("ZY", "ABC", now.minusSeconds(60)));
}
@Test void physicalLabelKeepsEveryIdBitAndIsCompact() {
long id = 2087379674555461633L;
String mark = LastlegOrderNoGenerator.compactCartonMark(id);
assertTrue(mark.length() <= 16);
assertEquals(id, Long.parseLong(mark.substring(3), 36));
assertNotEquals(mark, LastlegOrderNoGenerator.compactCartonMark(id + 1_000_000));
assertEquals(mark, new LastlegOrderNoGenerator().next("RETURN_CARTON", "92", id));
}
}
@@ -0,0 +1,41 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.repository.DocumentSequenceRepository;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import java.time.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class LastlegDocumentNumberGeneratorTest {
private static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC);
/** 多构造器情况下正式依赖可以被 Spring 正确选择。 */
@Test void springCreatesGeneratorWithRepository() {
try(var context=new AnnotationConfigApplicationContext()) {
context.registerBean(DocumentSequenceRepository.class,()->mock(DocumentSequenceRepository.class));
context.register(LastlegDocumentNumberGenerator.class);context.refresh();
assertThat(context.getBean(LastlegDocumentNumberGenerator.class)).isNotNull();
}
}
/** 主机使用 UTC 时仍按北京时间跨午夜,且四位流水不截断。 */
@Test void usesShanghaiDateAndIndependentSequenceKey() {
var sequences=mock(DocumentSequenceRepository.class);
when(sequences.next("YY:20261020")).thenReturn(1L,2L,9999L);
var numbers=new LastlegDocumentNumberGenerator(sequences,CLOCK);
assertThat(numbers.next("YY")).isEqualTo("YY202610200001");
assertThat(numbers.next("YY")).isEqualTo("YY202610200002");
assertThat(numbers.next("YY")).isEqualTo("YY202610209999");
verify(sequences,times(3)).next("YY:20261020");
}
/** 未登记类型不能消耗流水,分配异常或溢出时拒绝出号。 */
@Test void rejectsUnknownTypeAndOverflow() {
var sequences=mock(DocumentSequenceRepository.class);
var numbers=new LastlegDocumentNumberGenerator(sequences,CLOCK);
assertThatThrownBy(()->numbers.next("ZZZZ")).isInstanceOf(IllegalArgumentException.class);
verifyNoInteractions(sequences);
when(sequences.next("YY:20261020")).thenReturn(10000L);
assertThatThrownBy(()->numbers.next("YY")).isInstanceOf(BizException.class);
}
}
@@ -1,94 +1,30 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.mapper.LastlegOrderNoDailySequenceMapper;
import org.apache.ibatis.annotations.Insert;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LastlegOrderNoGeneratorTest {
@Mock LastlegOrderNoDailySequenceMapper sequenceMapper;
private LastlegOrderNoGenerator generator;
@BeforeEach
void setUp() {
generator = new LastlegOrderNoGenerator();
ReflectionTestUtils.setField(generator, "sequenceMapper", sequenceMapper);
/** 各作业类型使用不同前缀,同类型第二次调用必须递增。 */
@Test void routesEveryOperationalTypeToItsDailySequence() {
var generator=TestDocumentNumbers.orders();
Map.ofEntries(Map.entry("OUTBOUND","CK"),Map.entry("ONE_CLICK_DROPSHIP","DFCK"),
Map.entry("RELABEL_OUTBOUND","HBCK"),Map.entry("RETURN_OUTBOUND","THCK"),
Map.entry("DELIVERY","DL"),Map.entry("TRUCK","TR"),Map.entry("LOOSEBOARD","LB"),
Map.entry("EXCEPTION","EX"),Map.entry("DISCARD","CD"),Map.entry("INBOUND_PLAN","IP"),
Map.entry("RETURN_RECEIPT","RR"),Map.entry("RETURN_BATCH","RB"),Map.entry("RETURN_PACKING_PLAN","HX"))
.forEach((type,prefix)->{
assertEquals(prefix+"202610200001",generator.next(type));
assertEquals(prefix+"202610200002",generator.next(type));
});
assertThrows(IllegalArgumentException.class,()->generator.next("UNKNOWN"));
}
@Test
void usesShanghaiBusinessDateAndDatabaseAllocatedSequence() {
LocalDate date = LocalDate.of(2026, 8, 27);
when(sequenceMapper.currentValue()).thenReturn(1L, 2L);
assertEquals("LMO-260827-0001", generator.next(date));
assertEquals("LMO-260827-0002", generator.next(date));
verify(sequenceMapper, times(2)).nextValue(date);
}
@Test
void mapperUsesOneAtomicDatabaseOperationForConcurrentInstances() throws Exception {
Insert insert = LastlegOrderNoDailySequenceMapper.class
.getMethod("nextValue", LocalDate.class).getAnnotation(Insert.class);
assertTrue(insert.value()[0].contains("ON DUPLICATE KEY UPDATE"));
assertTrue(insert.value()[0].contains("LAST_INSERT_ID(last_sequence+1)"));
}
@Test
void crossDayAllocationUsesTheNewShanghaiBusinessDateSequence() {
LocalDate firstDay = LocalDate.of(2026, 8, 27);
LocalDate nextDay = firstDay.plusDays(1);
when(sequenceMapper.currentValue()).thenReturn(42L, 1L);
assertEquals("LMO-260827-0042", generator.next(firstDay));
assertEquals("LMO-260828-0001", generator.next(nextDay));
verify(sequenceMapper).nextValue(firstDay);
verify(sequenceMapper).nextValue(nextDay);
}
@Test
void rejectsDailySequenceBeyondFourDigits() {
when(sequenceMapper.currentValue()).thenReturn(10_000L);
assertThrows(IllegalStateException.class, () -> generator.next(LocalDate.of(2026, 8, 27)));
}
@Test void actualContainerNumberIsStableAndShared() {
LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator();
assertTrue(generator.forContainerNo("2087379674555461633").matches("FL-WH-\\d{8}-\\d{6}"));
assertEquals(generator.forContainerNo("MSCU1"), new LastlegOrderNoGenerator().forContainerNo("MSCU1"));
assertNotEquals(generator.forContainerNo("MSCU1"), generator.forContainerNo("CAB1"));
assertTrue(generator.forContainerNo("MSCU1234567890").length() <= 32);
assertThrows(IllegalArgumentException.class, () -> generator.forContainerNo(" "));
}
@Test void formatsBusinessWarehouseAndDate() {
String value = new LastlegOrderNoGenerator().next("OUTBOUND", "warehouse-LAX-01", 123L);
assertTrue(value.matches("OB-LAX-\\d{8}-000123"), value);
assertTrue(value.length() <= 24);
}
@Test void transferNumbersDoNotCollideWhenDatabaseIdSuffixesRepeat() {
String first = generator.next("TRANSFER", "warehouse-LAX-01", 123L);
String second = generator.next("TRANSFER", "warehouse-LAX-01", 1_000_123L);
String repeatedId = generator.next("WAREHOUSE_TRANSFER", "warehouse-LAX-01", 123L);
assertTrue(first.matches("WT-[0-9A-Z]{25}"), first);
assertTrue(first.length() <= 32);
assertNotEquals(first, second);
assertNotEquals(first, repeatedId);
/** 头程来源尾程号未纳入独立改号,保持同柜分组关系。 */
@Test void actualContainerNumberRemainsStableAndShared() {
var generator=TestDocumentNumbers.orders();
assertTrue(generator.forContainerNo("MSCU6639870").matches("FL-WH-\\d{8}-\\d{6}"));
assertEquals(generator.forContainerNo("MSCU6639870"),TestDocumentNumbers.orders().forContainerNo("MSCU6639870"));
assertNotEquals(generator.forContainerNo("MSCU6639870"),generator.forContainerNo("CSQU3054383"));
assertThrows(IllegalArgumentException.class,()->generator.forContainerNo(" "));
}
}
@@ -3,10 +3,10 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
class LoadingCompletedConsumerTest {
private final Map<String,String> frozenContainerNos=new HashMap<>();
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper loadingEvidence; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); }
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper loadingEvidence; @Mock LastlegDocumentNumberGenerator sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); }
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); }
@Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); }
@Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verifyNoInteractions(sequences); }
@Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
@Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor<LastlegOrder> captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); }
@Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","","MSCU6639870",0L,1L); }
@@ -15,7 +15,7 @@ class LoadingCompletedConsumerTest {
@Test void rejectsDifferentContainerNumberForSameSourceBookingBeforeAnyBusinessWrite(){ EventEnvelope source=event(); frozenContainerNos.put("book-1","CSQU3054383"); IllegalStateException error=assertThrows(IllegalStateException.class,()->service.consume(source)); assertTrue(error.getMessage().contains("sourceContainerBookingId")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
@Test void rejectsExistingOrderContainerDriftForSameSourceBooking(){ when(orders.lockContainerNumbers("book-1")).thenReturn(List.of("MSCU6639870","CSQU3054383")); assertThrows(IllegalStateException.class,()->service.consume(event())); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
@Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(first).outcome()); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(second).outcome()); verify(shippingGates,times(2)).initialize(eq("book-1"),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); }
@Test void replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); row.setStatus("PROCESSED"); when(inbox.selectById(event.eventId())).thenReturn(row); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.DUPLICATE, service.consume(event).outcome()); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); verify(histories,never()).insert(any(LastlegOrderStatusHistory.class)); }
@Test void replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); row.setStatus("PROCESSED"); when(inbox.selectById(event.eventId())).thenReturn(row); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.DUPLICATE, service.consume(event).outcome()); verifyNoInteractions(sequences); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); verify(histories,never()).insert(any(LastlegOrderStatusHistory.class)); }
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
@Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); }
@Test void concurrentDuplicateKeyWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox raced=new LastlegEventInbox(); raced.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(null,raced); doThrow(new org.springframework.dao.DuplicateKeyException("race")).when(inbox).insert(any(LastlegEventInbox.class)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("摘要冲突")); verify(orders,never()).countContainer(anyString(),anyString()); }
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.service;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class PhysicalCartonNumberTest {
/** 箱码不属于本次改号,完整保留内部 ID 的每一位以保证扫码唯一性。 */
@Test void preservesEveryIdBit() {
long id = 2087379674555461633L;
String mark = LastlegOrderNoGenerator.compactCartonMark(id);
assertTrue(mark.length() <= 16);
assertEquals(id, Long.parseLong(mark.substring(3), 36));
assertNotEquals(mark, LastlegOrderNoGenerator.compactCartonMark(id + 1_000_000));
assertEquals(mark, LastlegOrderNoGenerator.compactCartonMark(id));
assertThrows(IllegalArgumentException.class, () -> LastlegOrderNoGenerator.compactCartonMark(0));
}
}
@@ -1,60 +1,27 @@
package com.baiyue.lastleg.service;
import org.junit.jupiter.api.Test;
import java.math.BigInteger;
import java.util.List;
import java.util.HashSet;
import java.util.Map;
import java.util.UUID;
import java.util.stream.IntStream;
import static org.junit.jupiter.api.Assertions.*;
class ReturnBusinessNumberTest {
// 实物箱唛采用稳定BX编码,由CompactBusinessNumberServiceTest校验;此处仅验证独立业务编号。
private static final Map<String, String> TYPES = Map.of("RETURN", "RT", "RETURN_RECEIPT", "RR",
"RETURN_BATCH", "RB", "RETURN_RELABEL", "RETU");
@Test
void formerlyCollidingInternalIdsAndRepeatedIdsNoLongerDetermineBusinessNumbers() {
LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator();
long first = 2099800000000781889L, second = first + 1_000_000L;
assertEquals(Math.floorMod(first, 1_000_000), Math.floorMod(second, 1_000_000));
TYPES.forEach((type, prefix) -> {
String a = generator.next(type, "warehouse-LAX-01", first);
String b = generator.next(type, "warehouse-LAX-01", second);
String c = generator.next(type, "warehouse-LAX-01", first);
assertEquals(3, new HashSet<>(java.util.List.of(a, b, c)).size());
for (String value : java.util.List.of(a, b, c)) {
assertTrue(value.matches(prefix + "-[0-9A-Z]{25}"), value);
assertTrue(value.length() <= 32);
assertFalse(value.contains(Long.toString(first)));
}
});
}
@Test
void encodingPreservesAll128BitsIncludingLeadingZerosAndSignBits() {
for (UUID uuid : java.util.List.of(new UUID(0, 0), new UUID(-1, -1), new UUID(Long.MIN_VALUE, 1),
UUID.fromString("00000000-0000-4000-8000-000000000001"),
UUID.fromString("ffffffff-ffff-4fff-bfff-000000000001"))) {
String encoded = LastlegOrderNoGenerator.encodeReturnUuid(uuid);
assertEquals(25, encoded.length());
assertEquals(new BigInteger(uuid.toString().replace("-", ""), 16), new BigInteger(encoded, 36));
/** 独立单据不再依赖客户简称和内部 ID,各前缀从一开始。 */
@Test void businessPrefixesHaveIndependentFourDigitSequences() {
var generator=TestDocumentNumbers.documents();
for(String prefix:List.of("TH","THHB","TB","ZY","RR","RB","HX","CG","PR","PJ","PF","PN","PX","PG","CT","FB","FO","VR")) {
var values=List.of(generator.next(prefix),generator.next(prefix),generator.next(prefix));
assertEquals(3,new HashSet<>(values).size());
assertEquals(prefix+"202610200001",values.get(0));
assertEquals(prefix+"202610200003",values.get(2));
}
}
@Test
void independentInstancesGenerateDistinctNumbersDuringConcurrentBurst() {
var values = IntStream.range(0, 20_000).parallel()
.mapToObj(i -> new LastlegOrderNoGenerator().next("RETURN_RECEIPT", "42", 123L)).toList();
assertEquals(values.size(), new HashSet<>(values).size());
}
@Test
void nonReturnRulesRemainUnchangedAndReturnNormalizationIsLocaleIndependent() {
LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator();
for (String type : java.util.List.of("OUTBOUND", "RELABEL", "PROCUREMENT", "DISPATCH")) {
assertEquals(generator.next(type, "LAX", 123), generator.next(type, "LAX", 1_000_123));
}
assertTrue(generator.next(" return_receipt ", "42", 1).matches("RR-[0-9A-Z]{25}"));
/** 商业池和普通作业入口必须共享卡车及散板同类型序列。 */
@Test void commercialAndOperationalEntriesShareTheSameCounters() {
var documents=TestDocumentNumbers.documents();var operations=new LastlegOrderNoGenerator(documents);
assertEquals("TR202610200001",documents.next("TR"));
assertEquals("TR202610200002",operations.next("TRUCK"));
assertEquals("LB202610200001",operations.next("LOOSEBOARD"));
assertEquals("LB202610200002",documents.next("LB"));
}
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.repository.DocumentSequenceRepository;
import java.time.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/** 业务单测使用真实格式及隔离计数器;数据库并发由 Repository 集成测试覆盖。 */
public final class TestDocumentNumbers {
/** 测试工具不实例化。 */
private TestDocumentNumbers() {}
/** 每个测试生成器独立计数,类型前缀之间互不占号。 */
public static LastlegDocumentNumberGenerator documents() {
var sequences=mock(DocumentSequenceRepository.class);
var counters=new ConcurrentHashMap<String,AtomicLong>();
lenient().when(sequences.next(anyString())).thenAnswer(call->counters.computeIfAbsent(call.getArgument(0),key->new AtomicLong()).incrementAndGet());
return new LastlegDocumentNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-20T00:00:00Z"),ZoneOffset.UTC));
}
/** 海外类型映射同样使用生产生成器。 */
public static LastlegOrderNoGenerator orders() { return new LastlegOrderNoGenerator(documents()); }
}
@@ -196,7 +196,7 @@ class WarehouseTransferServiceTest {
@Mock LastlegOrderDetailMapper orderDetails;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
@Mock RelabelApplicationMapper relabelApplications;
@Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator();
@Spy LastlegOrderNoGenerator orderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders();
@Mock InventoryCommandService inventory;
@Mock InventoryCommandMapper inventoryCommands;
@Mock com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelService;
@@ -204,16 +204,16 @@ class WarehouseTransferServiceTest {
@Mock WarehouseTransferReceiptCoordinator receiptCoordinator;
@Mock com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory;
private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers;
@BeforeEach
void setUp() {
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.CompactBusinessNumberService.class);
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class);
service = new WarehouseTransferService();
var measurementRepository = new com.baiyue.lastleg.transfer.repository.TransferMeasurementRepository();
ReflectionTestUtils.setField(measurementRepository, "cartons", transferCartons);
ReflectionTestUtils.setField(service, "transferMeasurementRepository", measurementRepository);
ReflectionTestUtils.setField(service, "compactNumbers", compactNumbers);
org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030");
org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030");
ReflectionTestUtils.setField(service, "customerDirectory", customerDirectory);
lenient().when(customerDirectory.find("TENANT-A")).thenReturn(
new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer("TENANT-A", "C-A", "Customer A", null, null));
@@ -267,8 +267,7 @@ class WarehouseTransferServiceTest {
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
ReflectionTestUtils.setField(service, "relabelService", relabelService);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
lenient().when(orderNoGenerator.next(anyString(), any(), anyLong()))
.thenReturn("WT-0000000000000000000000001");
lenient().doReturn("ZY202610200001").when(orderNoGenerator).next(anyString());
lenient().when(inventoryAllocations.selectList(any())).thenReturn(List.of());
lenient().when(transferReceipts.selectList(any())).thenReturn(List.of());
lenient().when(transferCartons.listOrder(anyString(), anyLong())).thenReturn(List.of());
@@ -80,7 +80,7 @@ class UsTruckOrderServiceTest {
@Test
void createStoresBothContactsAndMinuteTimesWithoutInventingPallets() {
when(orderNoGenerator.next(anyString(), anyString(), anyLong())).thenReturn("TK1");
when(orderNoGenerator.next(anyString())).thenReturn("TK1");
when(histories.listOrder(anyString(), anyLong())).thenReturn(historyRows);
when(orders.insert(any(UsTruckOrder.class))).thenAnswer(call -> { order = call.getArgument(0); return 1; });
var request = create("PALLET_15", "new-truck");
@@ -56,6 +56,11 @@
"com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity"
],
"reviewedClassDependencies": [
{
"source": "com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels",
"target": "com.baiyue.lastleg.dispatch.port.DispatchViews",
"reason": "外配业务只组合原排车公开只读详情;精确边对应既有反向合同,不开放派送持久化或具体业务服务。"
},
{
"source": "com.baiyue.lastleg.dispatch.transport.TransportTaskService",
"target": "com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository",