This commit is contained in:
2026-11-03 16:14:51 +08:00
parent 7b5ef9ba64
commit 1506576c67
17 changed files with 254 additions and 52 deletions
@@ -26,6 +26,9 @@ public class DispatchLooseboardExecutionController {
/** 返回逐票照片及服务端节点资格。 */
@GetMapping @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<Detail> get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number){return ApiResponse.success(service.get(DispatchActorFactory.platform(user),number));}
/** 显式确认取货,不由上传照片代替操作者提交。 */
@PostMapping("/pickup-complete") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<Detail> completePickup(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody Confirm request){return ApiResponse.success(service.completePickup(DispatchActorFactory.platform(user),number,request));}
/** 上传不提前推进业务状态。 */
@PostMapping(value="/{itemId}/{phase}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
@@ -110,7 +110,7 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
var row=required(number,lock);
if(!"ACTIVE".equals(row.getStatus()) || !Set.of("DELIVERING","DELIVERED").contains(row.getStage()) || row.getDepartedAt()==null)return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.of(),"本车实际发运尚未完成");
if(looseboardFacts!=null && !looseboardFacts.cargo(row.getId()).isEmpty()) {
boolean ready=looseboardFacts.complete(row,com.baiyue.lastleg.dispatch.service.DispatchLooseboardFacts.Phase.PICKUP)
boolean ready=(looseboardFacts.remoteItemIds(row.getId()).isEmpty() || looseboardFacts.pickupConfirmed(row)) && looseboardFacts.complete(row,com.baiyue.lastleg.dispatch.service.DispatchLooseboardFacts.Phase.PICKUP)
&& looseboardFacts.complete(row,com.baiyue.lastleg.dispatch.service.DispatchLooseboardFacts.Phase.ARRIVAL);
if(looseboardFacts.direct(row))return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),looseboardFacts.outcomes(row),ready?null:"散板逐票取货和送达照片尚未齐全");
if(!ready)return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.of(),"混排散板逐票取货和送达照片尚未齐全");
@@ -36,9 +36,13 @@ public interface DispatchAggregateMapper {
""";
String REVIEW_DISPATCH = "CASE d.review_state WHEN 'DRAFT' THEN 'NOT_SUBMITTED' WHEN 'DISPATCH_PENDING' THEN 'PENDING' WHEN 'DISPATCH_REJECTED' THEN 'REJECTED' ELSE 'APPROVED' END";
String REVIEW_LOADING = "CASE d.review_state WHEN 'LOADING_PENDING' THEN 'PENDING' WHEN 'LOADING_REJECTED' THEN 'REJECTED' WHEN 'APPROVED' THEN 'APPROVED' ELSE 'NOT_SUBMITTED' END";
// 仓内已发运而异地货尚未确认取齐,同时属于装车后和派送中;确认取齐后仅保留派送中。
String PICKUP_PENDING = "(d.stage='DELIVERING' AND d.departed_at IS NOT NULL AND d.shipped_package_count &lt; d.package_count)";
String CURRENT_FILTER = """
<if test='q.status != "ALL"'> AND d.status=#{q.status}</if>
<if test='q.stage != null'> AND d.stage=#{q.stage}</if>
<if test='q.stage != null'><choose><when test='q.stage == "AFTER_LOADING"'> AND (d.stage='AFTER_LOADING' OR
""" + PICKUP_PENDING + """
)</when><otherwise> AND d.stage=#{q.stage}</otherwise></choose></if>
<if test='q.section == "VEHICLE_INFO"'> AND d.stage='BEFORE_LOADING' AND d.review_state IN ('DRAFT','DISPATCH_REJECTED','LOADING_REJECTED')</if>
<if test='q.section == "DISPATCH_REVIEW"'> AND d.stage='BEFORE_LOADING' AND d.review_state='DISPATCH_PENDING'</if>
<if test='q.section == "LOADING_REVIEW"'> AND d.stage='BEFORE_LOADING' AND d.review_state='LOADING_PENDING'</if>
@@ -49,7 +53,10 @@ public interface DispatchAggregateMapper {
record Search(String dispatchNo, String vehiclePlate, String warehouseId, String customerId,
String fbaAppointmentNo, String status, String stage, String section, String reviewStatus,
long offset, int limit) { }
record Summary(String status, String stage, String reviewState, long quantity) { }
record Summary(String status, String stage, String reviewState, long quantity,long pendingPickups) {
@AutomapConstructor public Summary { }
public Summary(String status,String stage,String reviewState,long quantity){this(status,stage,reviewState,quantity,0);}
}
/** 首次创建整车所有权及初始版本,不写customer_id。 */
@Insert("""
@@ -95,7 +102,7 @@ public interface DispatchAggregateMapper {
long count(@Param("q") Search query);
/** 基集统计不套当前阶段和审核筛选,客户通过明细EXISTS去重。 */
@Select("<script>SELECT d.status,d.stage,d.review_state AS reviewState,COUNT(*) AS quantity FROM ll_dispatch_order d WHERE 1=1 "
@Select("<script>SELECT d.status,d.stage,d.review_state AS reviewState,COUNT(*) AS quantity,SUM(CASE WHEN " + PICKUP_PENDING + " THEN 1 ELSE 0 END) AS pendingPickups FROM ll_dispatch_order d WHERE 1=1 "
+ BASE_FILTER + " GROUP BY d.status,d.stage,d.review_state</script>")
List<Summary> summaries(@Param("q") Search query);
@@ -9,6 +9,9 @@ public interface DispatchLooseboardEvidenceMapper {
/** 按原车读取不可变照片命令,版本顺序用于恢复和审计。 */
@Select("SELECT snapshot_json FROM ll_dispatch_history WHERE dispatch_id=#{id} AND action='LOOSEBOARD_PHOTO' ORDER BY record_version,id")
List<String> snapshots(long id);
/** 取货完成是独立确认动作,绑定当前货物及审核轮次。 */
@Select("SELECT snapshot_json FROM ll_dispatch_history WHERE dispatch_id=#{id} AND action='LOOSEBOARD_PICKUP_COMPLETE' ORDER BY record_version,id")
List<String> pickupConfirmations(long id);
/** 同客户原单逐排车明细去重汇总送达量,分车派送不能提前完成原单。 */
@Select("""
SELECT COALESCE(SUM(done.packages),0) FROM (
@@ -196,6 +196,15 @@ public interface DispatchSourceMapper {
@Update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL,released_at=#{at},updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND source_pallet_key=#{key} AND status='ACTIVE'")
int release(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("key")String key,@Param("at")LocalDateTime at);
/** 仅认可本车本客户同一来源的既有释放事实;他车重新占用后不得继续确认取货。 */
@Select("SELECT COUNT(*) FROM ll_dispatch_pallet_lock original WHERE original.dispatch_order_id=#{dispatch} "
+ "AND original.customer_id=#{customer} AND original.source_pallet_key=#{key} AND original.status='RELEASED' "
+ "AND original.active_key IS NULL AND original.released_at IS NOT NULL "
+ "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock current_claim WHERE current_claim.customer_id=#{customer} "
+ "AND current_claim.source_pallet_key=#{key} AND current_claim.status='ACTIVE')")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
int releasedByDispatch(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("key")String key);
/** 原计划收货只补齐本逻辑成员的实际实托和已取得的物理占用引用。 */
@Update("UPDATE ll_dispatch_pallet_lock SET warehouse_pallet_id=#{pallet},physical_lock_id=#{physical},updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND plan_pallet_id=#{plan} AND status='ACTIVE' AND warehouse_pallet_id IS NULL AND physical_lock_id IS NULL")
int materialize(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("plan")Long plan,@Param("pallet")Long pallet,@Param("physical")long physical,@Param("at")LocalDateTime at);
@@ -12,6 +12,8 @@ public class DispatchLooseboardEvidenceRepository {
public DispatchLooseboardEvidenceRepository(DispatchLooseboardEvidenceMapper mapper){this.mapper=mapper;}
/** 历史始终按公共版本递增返回。 */
public List<String> snapshots(long id){return mapper.snapshots(id);}
/** 独立读取人工确认取货记录,不将上传照片等同于完成。 */
public List<String> pickupConfirmations(long id){return mapper.pickupConfirmations(id);}
/** 返回同客户原单已经有送达照片的真实运输箱数。 */
public long deliveredPackages(String customer,String number){return mapper.deliveredPackages(customer,number);}
}
@@ -112,6 +112,10 @@ public class DispatchSourceRepository {
public List<DispatchSourceMapper.Claim> claims(String customer, String key, LocalDateTime at) { return source.claims(customer,key,at); }
/** 精确释放本车本来源,不释放他车重新取得的键。 */
public int release(long dispatch,com.baiyue.lastleg.dispatch.entity.DispatchItem item,LocalDateTime at) {return source.release(dispatch,item.getCustomerId(),item.getSourceKey(),at);}
/** 上门取货确认可复用本车已释放事实,不把缺失、失效或他车占用当作成功。 */
public boolean releasedByDispatch(long dispatch,com.baiyue.lastleg.dispatch.entity.DispatchItem item) {
return source.releasedByDispatch(dispatch,item.getCustomerId(),item.getSourceKey())==1;
}
/** 原计划收货的物理身份与其活动键同事务补齐,不复制来源占用。 */
public int materialize(long dispatch,String customer,long plan,long pallet,long physical,LocalDateTime at){return source.materialize(dispatch,customer,plan,pallet,physical,at);}
/** 保存正式源/实托占用。 */
@@ -1049,9 +1049,20 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 先锁原物理拥有者,再逐逻辑键释放;同托仍有活动成员时物理排他继续保留。 */
@Transactional(propagation=Propagation.MANDATORY)
public void releaseSources(DispatchAggregate row,Actor actor,List<DispatchItem> items,LocalDateTime at) {
releaseSources(row,actor,items,at,false);
}
/** 取货确认独立于照片保存;本车已经释放的同一来源无需二次释放。 */
@Transactional(propagation=Propagation.MANDATORY)
public void releasePickupSources(DispatchAggregate row,Actor actor,List<DispatchItem> items,LocalDateTime at) {
releaseSources(row,actor,items,at,true);
}
/** 常规释放保持严格校验,取货确认仅额外认可本车已提交的释放记录。 */
private void releaseSources(DispatchAggregate row,Actor actor,List<DispatchItem> items,LocalDateTime at,boolean pickup) {
var owner=new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,actor.operatorId(),null);
var original=physicalClaims.lockOwner(owner);
for(var item:items)if(sources.release(row.getId(),item,at)!=1)throw conflict("原逻辑来源占用已变化");
for(var item:items)if(sources.release(row.getId(),item,at)!=1 && !(pickup && sources.releasedByDispatch(row.getId(),item)))throw conflict("原逻辑来源占用已变化");
physicalClaims.releaseIfUnused(owner,original.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock::getId).toList(),at);
}
@@ -1200,6 +1211,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
for(var group:groups) {
if(Status.CANCELLED.name().equals(group.status())) { cancelled+=group.quantity(); continue; }
active+=group.quantity(); var stage=Stage.valueOf(group.stage()); stages.merge(stage,group.quantity(),Long::sum);
stages.merge(Stage.AFTER_LOADING,group.pendingPickups(),Long::sum);
if(stage==Stage.BEFORE_LOADING) {
// 三分区表示当前待办,驳回只能计入客服处理,不能留在审核队列。
var review=ReviewState.valueOf(group.reviewState());
@@ -1290,6 +1302,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
private boolean reconciled(DispatchAggregate row) { long expected=warehouseCounts(row).packageCount();return expected>0 && expected==n(row.getPickedPackageCount()); }
/** 异地散板身份只从本车权威关联读取,不由请求提供。 */
public Set<Long> remoteItemIds(DispatchAggregate row){return looseboardFacts==null?Set.of():looseboardFacts.remoteItemIds(row.getId());}
/** 仓内货与异地取货同车时,仓内装车完成即开始派送,不等待异地节点资料。 */
public boolean mixedPickup(Context context){var row=repository.find(context.dispatchNo());return row!=null && !remoteItemIds(row).isEmpty() && !warehouseItems(row).isEmpty();}
/** 已保存取货照片的异地货量与仓库实发分开核对,正式发运不得覆盖前者。 */
public long pickedRemotePackages(DispatchAggregate row){return looseboardFacts==null?0:looseboardFacts.pickedRemotePackages(row);}
/** 仓内实物作业范围,普通未收货计划仍保留并按原规则阻断。 */
@@ -47,7 +47,7 @@ public class DispatchCompletionService implements DispatchCompletionPort {
if(!Status.ACTIVE.name().equals(row.getStatus()) || !Set.of(Stage.DELIVERING.name(),Stage.DELIVERED.name()).contains(row.getStage()))throw new IllegalStateException("本车尚无合法在途运输事实");
boolean changed=false;var now=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);
boolean photosReady=looseboardFacts==null || looseboardFacts.cargo(row.getId()).isEmpty()
|| looseboardFacts.complete(row,DispatchLooseboardFacts.Phase.PICKUP) && looseboardFacts.complete(row,DispatchLooseboardFacts.Phase.ARRIVAL);
|| (looseboardFacts.remoteItemIds(row.getId()).isEmpty() || looseboardFacts.pickupConfirmed(row)) && looseboardFacts.complete(row,DispatchLooseboardFacts.Phase.PICKUP) && looseboardFacts.complete(row,DispatchLooseboardFacts.Phase.ARRIVAL);
boolean complete=facts.allNormallySigned() && photosReady;
if(Stage.DELIVERING.name().equals(row.getStage()) && complete) {
if(facts.completedAt()==null || facts.factsHash()==null || facts.factsHash().isBlank() || row.getDepartedAt()==null)throw new IllegalStateException("缺少正常签收完成依据");
@@ -36,12 +36,19 @@ public class DispatchLoadingDepartureService {
var identity=new CommandIdentity(Action.LOADING_COMPLETE,request.idempotencyKey(),request.recordVersion(),
hashes.command(actor,Action.LOADING_COMPLETE,number,request.recordVersion(),request));
var original=workflow.replay(context,actor,identity);if(original.isPresent())return original.get();
var loaded=workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(),
boolean mixed=workflow.mixedPickup(context);
// 兼容已真实确认装车的混排单:新命令按当前版本与原实载复验,不重复仓内装车。
boolean resume=mixed && context.stage()==Stage.AFTER_LOADING;
if(resume){
workflow.requireFreshArrangement(context,request.recordVersion());
if(!java.util.Objects.equals(workflow.loading(actor,number).manifestHash(),request.manifestHash()))throw new BizException(ResultCode.CONFLICT,"装车清单已变化,请刷新后重试");
}
var loaded=resume?null:workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(),
stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.remark()));
var ready=DispatchLoadingDocumentService.complete(documents.read(context.dispatchId()).photos());
var ready=mixed || DispatchLoadingDocumentService.complete(documents.read(context.dispatchId()).photos());
var finalState=ready?departAfterDocuments(actor,number,request.idempotencyKey()):loaded;
var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),finalState.recordVersion(),
finalState.status(),finalState.stage(),finalState.occurredAt(),finalState.effect(),finalState.counts(),loaded.result(),false,false);
finalState.status(),finalState.stage(),finalState.occurredAt(),finalState.effect(),finalState.counts(),resume?new DispatchResults.Loaded(context.loadingCompletionHash(),context.loadingCompletedAt(),workflow.loading(actor,number).confirmedLoad()):loaded.result(),false,false);
commands.append(context.dispatchId(),actor,identity,result);
return result;
}
@@ -60,7 +67,7 @@ public class DispatchLoadingDepartureService {
hashes.source(List.of(number,actor.operatorId(),context.recordVersion(),fact.arrangementBusinessHash())));
var arranged=workflow.recordArrangement(context,actor,arrangementIdentity,fact,false);
var departed=departure.depart(actor,number,new DispatchCommands.Depart(arranged.recordVersion(),
stepKey(number,key,"DEPART"),"五张装车照片已齐全,自动进入派送中"));
stepKey(number,key,"DEPART"),workflow.mixedPickup(context)?"混排仓内装车完成,开始派送及异地取货":"五张装车照片已齐全,自动进入派送中"));
if(departed.stage()!=Stage.DELIVERING)
throw new BizException(ResultCode.CONFLICT,"装车发运结果未核平");
return departed;
@@ -25,9 +25,10 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
public class DispatchLooseboardExecutionService {
public record Save(@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
@NotNull @Size(max=20) List<@NotNull @Positive Long> fileIds) {}
public record Confirm(@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey) {}
public record Item(String itemId,String customerId,String orderNo,String destination,int packages,List<File> pickupPhotos,
List<File> arrivalPhotos,boolean canPickup,boolean canArrive) {}
public record Detail(String dispatchNo,long recordVersion,String stage,boolean direct,List<Item> items) {}
List<File> arrivalPhotos,boolean canPickup,boolean canArrive,String warehouseNotice,boolean pickupRequired) {}
public record Detail(String dispatchNo,long recordVersion,String stage,boolean direct,List<Item> items,boolean canCompletePickup,boolean pickupCompleted) {}
public record Download(File file,byte[] bytes) {}
private final DispatchAggregateService workflow;
private final DispatchAggregateRepository repository;
@@ -73,7 +74,7 @@ public class DispatchLooseboardExecutionService {
if(request.fileIds().size()>20 || new HashSet<>(request.fileIds()).size()!=request.fileIds().size())throw conflict("每个节点最多保存20张不重复照片");
var previous=phase==Phase.PICKUP?item.pickupPhotos():item.arrivalPhotos();
if(request.fileIds().isEmpty() && previous.isEmpty())throw conflict("请先选择照片");
if(request.fileIds().isEmpty() && ("DELIVERED".equals(row.getStage()) || phase==Phase.PICKUP && !item.arrivalPhotos().isEmpty()))throw conflict("已送达或已有送达照片的节点至少保留一张凭证,可先追加新照片再删除旧照片");
if(request.fileIds().isEmpty() && ("DELIVERED".equals(row.getStage()) || phase==Phase.PICKUP && (facts.pickupConfirmed(row) || !item.arrivalPhotos().isEmpty())))throw conflict("已确认取货或送达的节点至少保留一张凭证,可先追加新照片再删除旧照片");
var old=facts.history(row.getId());
boolean pickupRecorded=old.stream().anyMatch(e->e.phase()==Phase.PICKUP && matches(e,cargo) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty());
boolean arrivalRecorded=old.stream().anyMatch(e->e.phase()==Phase.ARRIVAL && matches(e,cargo) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty());
@@ -85,14 +86,6 @@ public class DispatchLooseboardExecutionService {
long original=row.getRecordVersion();row.setRecordVersion(original+1);row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));
if(repository.saveState(row,original)!=1)throw conflict("排车版本竞争失败");
if(repository.history(IdWorker.getId(),row,"LOOSEBOARD_PHOTO",actor.operatorId(),phase.name(),write(evidence),row.getUpdatedAt())!=1)throw conflict("散板照片保存失败");
if(current.direct() && "LOADING".equals(row.getStage()) && phase==Phase.PICKUP && facts.complete(row,Phase.PICKUP))depart(actor,row,at);
if(!current.direct() && phase==Phase.PICKUP && !pickupRecorded && !photos.isEmpty() && facts.remoteItemIds(row.getId()).contains(itemId)){
workflow.requireRemotePickupCapacity(row);
row.setShippedPackageCount(Math.addExact(row.getShippedPackageCount(),cargo.item().getPackageCount()));
orders.progress(cargo.item().getCustomerId(),cargo.orderNo(),actor.operatorId(),0);
workflow.releaseSources(row,actor,List.of(cargo.item()),row.getUpdatedAt());
transition(row,current.stage(),at,actor,"混排异地散板实际取货照片已保存");
}
if(phase==Phase.ARRIVAL && !photos.isEmpty()){
if(!arrivalRecorded)orders.progress(cargo.item().getCustomerId(),cargo.orderNo(),actor.operatorId(),facts.deliveredPackages(cargo.item().getCustomerId(),cargo.orderNo()));
if(current.direct() && "DELIVERING".equals(row.getStage()) && facts.complete(row,Phase.PICKUP) && facts.complete(row,Phase.ARRIVAL)){
@@ -102,7 +95,7 @@ public class DispatchLooseboardExecutionService {
}
}
// 已持整车父锁,可普通读已提交签收事实;先签收后补照片也能完成,不伪造新派送版本信号。
if(!current.direct() && "DELIVERING".equals(row.getStage()) && facts.complete(row,Phase.PICKUP) && facts.complete(row,Phase.ARRIVAL)){
if(!current.direct() && "DELIVERING".equals(row.getStage()) && (facts.remoteItemIds(row.getId()).isEmpty() || facts.pickupConfirmed(row)) && facts.complete(row,Phase.PICKUP) && facts.complete(row,Phase.ARRIVAL)){
var normal=deliveries.normalDeliveryFacts(workflow.preview(actor,number));
if(normal.allNormallySigned()){
transition(row,"DELIVERED",at,actor,"混排全部货物签收且散板取货、送达照片齐全");
@@ -111,6 +104,32 @@ public class DispatchLooseboardExecutionService {
}
return detail(row);
}
/** 所有取货照片齐全后显式确认;公共版本、父锁和原键回放防止无感发车及重复计量。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public Detail completePickup(Actor actor,String number,Confirm request){
workflow.lockForArrangement(actor,number);var row=required(number);
String hash=hashes.source(Arrays.asList(number,request));
var replay=facts.confirmations(row.getId()).stream().filter(e->e.operatorId().equals(actor.operatorId()) && e.idempotencyKey().equals(request.idempotencyKey())).findFirst();
if(replay.isPresent()){if(!replay.get().requestHash().equals(hash))throw conflict("相同命令标识不能修改取货确认内容");return detail(row);}
if(row.getRecordVersion().longValue()!=request.recordVersion())throw conflict("排车版本已变化,请刷新后重试");
var current=detail(row);if(!current.canCompletePickup())throw conflict("当前不可确认取货,请核对逐票照片及作业状态");
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);
var confirmation=new Evidence("ALL",null,null,Phase.PICKUP,row.getCargoHash(),row.getReviewRound(),List.of(),at,actor.operatorId(),request.idempotencyKey(),hash);
long version=row.getRecordVersion();row.setRecordVersion(version+1);row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));
if(repository.saveState(row,version)!=1)throw conflict("取货确认版本竞争失败");
if(repository.history(IdWorker.getId(),row,"LOOSEBOARD_PICKUP_COMPLETE",actor.operatorId(),"取货完成",write(confirmation),LocalDateTime.ofInstant(at,ZoneOffset.UTC))!=1)throw conflict("取货完成记录保存失败");
if(current.direct())depart(actor,row,at);
else{
workflow.requireRemotePickupCapacity(row);var remote=facts.remoteItemIds(row.getId());
var picked=facts.cargo(row.getId()).stream().filter(c->remote.contains(c.item().getId())).toList();
long warehouse=row.getDepartedAt()==null?0:workflow.warehouseItems(row).stream().mapToLong(i->i.getPackageCount()).sum();
row.setShippedPackageCount(Math.toIntExact(warehouse+picked.stream().mapToLong(c->c.item().getPackageCount()).sum()));
for(var c:picked)orders.progress(c.item().getCustomerId(),c.orderNo(),actor.operatorId(),0);
workflow.releasePickupSources(row,actor,picked.stream().map(Cargo::item).toList(),LocalDateTime.ofInstant(at,ZoneOffset.UTC));
transition(row,row.getDepartedAt()==null?"AFTER_LOADING":"DELIVERING",at,actor,"人工确认本车上门取货完成");
}
return detail(row);
}
/** 下载只允许当前明细已保存的节点照片,未保存上传不授权读取。 */
public Download content(Actor actor,String number,long itemId,Phase phase,long fileId){
var value=get(actor,number);var item=value.items().stream().filter(i->i.itemId().equals(Long.toString(itemId))).findFirst().orElseThrow(()->conflict("散板明细不存在"));
@@ -142,14 +161,17 @@ public class DispatchLooseboardExecutionService {
/** 混排装车确认后即可逐票取送;照片不代替仓内装车资料齐全后的正式发运。 */
private Detail detail(DispatchAggregate row){
boolean direct=facts.direct(row);var evidence=facts.current(row);boolean active="ACTIVE".equals(row.getStatus());
var pendingInbound=facts.pendingInboundItemIds(row.getId());
var remote=facts.remoteItemIds(row.getId());boolean confirmed=facts.pickupConfirmed(row);
var items=facts.cargo(row.getId()).stream().map(c->{
var pickup=evidence.stream().filter(e->e.phase()==Phase.PICKUP && matches(e,c)).flatMap(e->e.files().stream()).toList();
var arrival=evidence.stream().filter(e->e.phase()==Phase.ARRIVAL && matches(e,c)).flatMap(e->e.files().stream()).toList();
boolean mixedPhotoStage=!direct && Set.of("AFTER_LOADING","DELIVERING").contains(row.getStage());
boolean canPickup=active && (Set.of("DELIVERING","DELIVERED").contains(row.getStage()) || (direct?"LOADING".equals(row.getStage()) && "APPROVED".equals(row.getReviewState()):mixedPhotoStage));
boolean canArrive=active && (mixedPhotoStage || Set.of("DELIVERING","DELIVERED").contains(row.getStage())) && !pickup.isEmpty();
return new Item(c.item().getId().toString(),c.item().getCustomerId(),c.orderNo(),Objects.toString(c.item().getDestinationWarehouseCode(),c.item().getDeliveryAddress()),c.item().getPackageCount(),pickup,arrival,canPickup,canArrive);
}).toList();return new Detail(row.getDispatchNo(),row.getRecordVersion(),row.getStage(),direct,items);
boolean canArrive=active && (mixedPhotoStage || Set.of("DELIVERING","DELIVERED").contains(row.getStage())) && (c.item().getWarehousePalletId()!=null || confirmed && !pickup.isEmpty());
return new Item(c.item().getId().toString(),c.item().getCustomerId(),c.orderNo(),Objects.toString(c.item().getDestinationWarehouseCode(),c.item().getDeliveryAddress()),c.item().getPackageCount(),pickup,arrival,canPickup,canArrive,pendingInbound.contains(c.item().getId())?c.orderNo()+":还没拣货入库":null,remote.contains(c.item().getId()));
}).toList();boolean ready=active && !confirmed && (direct?"LOADING".equals(row.getStage()) && "APPROVED".equals(row.getReviewState()):!remote.isEmpty() && Set.of("AFTER_LOADING","DELIVERING").contains(row.getStage())) && facts.complete(row,Phase.PICKUP);
return new Detail(row.getDispatchNo(),row.getRecordVersion(),row.getStage(),direct,items,ready,confirmed);
}
/** 业务原单范围取主流程认证后的本车,不接受客户端指定归属。 */
private DispatchAggregate required(String number){var row=repository.find(number);if(row==null)throw conflict("排车单不存在");return row;}
@@ -32,6 +32,15 @@ public class DispatchLooseboardFacts {
for(var item:items){var source=display.find(identities,item.getCustomerId(),item.getOrderNo(),item.getPlanPalletId());if(source!=null)result.add(new Cargo(item,source.orderNo()));}
return List.copyOf(result);
}
/** 送货到仓尚无入仓实托时逐票提示,不将上门取货误报为待入库。 */
public Set<Long> pendingInboundItemIds(long id){
var items=aggregates.cargo(id);
var sources=display.sources(items.stream().map(i->new Reference(i.getCustomerId(),i.getOrderNo(),i.getPlanPalletId())).toList());
return items.stream().filter(i->i.getWarehousePalletId()==null).filter(i->{
var source=display.find(sources,i.getCustomerId(),i.getOrderNo(),i.getPlanPalletId());
return source!=null && "DELIVERY_TO_WAREHOUSE".equals(source.deliveryMode());
}).map(DispatchItem::getId).collect(java.util.stream.Collectors.toUnmodifiableSet());
}
/** 全车确为散板且没有真实扫码才可跳过仓库出库;历史已出库事实不能抹除。 */
public boolean direct(DispatchAggregate row){
var cargo=cargo(row.getId());return !cargo.isEmpty() && cargo.size()==aggregates.cargo(row.getId()).size() && cargo.stream().allMatch(c->c.item().getWarehousePalletId()==null)
@@ -46,13 +55,17 @@ public class DispatchLooseboardFacts {
/** 仓内作业保留全部普通来源及已存在真实实托的历史散板。 */
public List<DispatchItem> warehouseItems(long dispatchId){var remote=remoteItemIds(dispatchId);return aggregates.cargo(dispatchId).stream().filter(i->!remote.contains(i.getId())).toList();}
/** 混排实际发运量只在保存本票取货照片后包含异地货物,不提前登记已装车。 */
public long pickedRemotePackages(DispatchAggregate row){var remote=remoteItemIds(row.getId());var evidence=history(row.getId()).stream().filter(e->Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound()).toList();return cargo(row.getId()).stream()
public long pickedRemotePackages(DispatchAggregate row){if(!pickupConfirmed(row))return 0;var remote=remoteItemIds(row.getId());var evidence=history(row.getId()).stream().filter(e->Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound()).toList();return cargo(row.getId()).stream()
.filter(c->remote.contains(c.item().getId()) && evidence.stream().anyMatch(e->e.phase()==Phase.PICKUP && matches(e,c) && !e.files().isEmpty()))
.mapToLong(c->c.item().getPackageCount()).sum();}
/** 命令历史只读,JSON损坏明确失败,不静默当作照片缺失。 */
public List<Evidence> history(long id){return repository.snapshots(id).stream().map(value->{
try{return json.readValue(value,Evidence.class);}catch(Exception error){throw new IllegalStateException("散板照片履约历史读取失败",error);}
}).toList();}
/** 保存确认快照后才计入取货完成,历史轮次不影响当前作业。 */
public boolean pickupConfirmed(DispatchAggregate row){return confirmations(row.getId()).stream().anyMatch(e->Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound());}
/** 确认历史沿用照片事实身份结构,读取失败必须显式报错。 */
public List<Evidence> confirmations(long id){return repository.pickupConfirmations(id).stream().map(value->{try{return json.readValue(value,Evidence.class);}catch(Exception error){throw new IllegalStateException("取货完成历史读取失败",error);}}).toList();}
/** 退回重审或换货后,旧照片仍在历史但不再代替本轮执行证据。 */
public List<Evidence> current(DispatchAggregate row){
var latest=new LinkedHashMap<String,Evidence>();
@@ -60,10 +73,11 @@ public class DispatchLooseboardFacts {
.forEach(e->latest.put(e.itemId()+":"+e.phase(),e));
return List.copyOf(latest.values());
}
/** 每一票都须有自己的有效照片,不能用全车总数代替。 */
/** 仓内实托走原出库签收链,照片节点只核对尚待上门取货的散板,不跨票替代。 */
public boolean complete(DispatchAggregate row,Phase phase){
var cargo=cargo(row.getId());var evidence=current(row);
return !cargo.isEmpty() && cargo.stream().allMatch(c->evidence.stream().anyMatch(e->e.phase()==phase && matches(e,c) && !e.files().isEmpty()));
return !cargo.isEmpty() && cargo.stream().allMatch(c->c.item().getWarehousePalletId()!=null
|| evidence.stream().anyMatch(e->e.phase()==phase && matches(e,c) && !e.files().isEmpty()));
}
/** 精确校验客户、明细和商业原单三者,不允许跨票复用照片。 */
public static boolean matches(Evidence e,Cargo c){return e.itemId().equals(c.item().getId().toString()) && e.customerId().equals(c.item().getCustomerId()) && e.orderNo().equals(c.orderNo());}
@@ -4,20 +4,40 @@ info:
version: 1.0.0
description: >-
仅同客户权威关联的本土散板明细进入照片节点。全车为散板且尚无真实扫码时,审核通过后逐票保存取货照片,
自动从LOADING进入DELIVERING;不生成库存、PDA拣货或仓库出库事实。逐票保存送达照片后自动DELIVERED并结案释放车辆司机。
混排从AFTER_LOADING或DELIVERING可逐票保存取货及送达照片;同票须先取货再送达,不同票不限制取送顺序。
装车后保存照片保持原阶段,保留原PDA出库及装车资料齐全后进入派送的链路;全部仓内货签收且散板照片齐全才可整车完结。既有真实扫码不可抹除或倒改。
全部齐全才显示取货完成按钮;显式确认后从LOADING进入DELIVERING。不生成库存、PDA拣货或仓库出库事实。逐票保存送达照片后自动DELIVERED并结案释放车辆司机。
混排从AFTER_LOADING或DELIVERING可逐票保存取货及送达照片;异地货同票须先取货再送达,不同票不限制取送顺序。
仓内实托走原出库签收链,不要求散板节点照片;页面按pickupRequired仅展示本趟上门取货货物,送货到仓尚未入库则保留warehouseNotice提示。
混排仓内真实装车确认后即发运仓内货,不等待异地取货或五类装车照片;剩余装车资料可以补传。
仓内已发运而异地货尚未确认取齐时,同时展示在装车后和派送中,两页签计数允许重叠但总数不重复。
取货照片齐全不自动完成取货;点击取货完成后仅在派送中展示,异地货量只在该确认动作计入。全部仓内货签收且散板确认取货并送达照片齐全才可整车完结。
图片只接受10MiB以内JPG或PNG,每票每节点最多20张。保存提交该节点完整保留列表,支持追加和逐张删除;空列表代表删除最后一张。
整车已完结的节点及已有送达照片的取货节点至少保留一张凭证,可先追加再删除;修改照片不撤销已执行的取货、发运或送达,不重复累计货量。
上传不等于保存,保存用公共版本和幂等键,失败同事务回滚。历史照片列表保留审计,仅最新列表授权预览。
本土散板号只用LB原单,不生成入仓码、柜号或虚构派送单号。分车运输按已保存送达照片的明细去重累计箱数,整票送达后才完成原单。
paths:
/api/platform/v1/last-leg/dispatches/{number}/looseboard-execution/pickup-complete:
parameters:
- { name: number, in: path, required: true, schema: { type: string } }
post:
summary: 人工确认本车全部上门取货完成
description: 需要platform:overseas-warehouse:dispatch:edit。逐票取货照片齐全、当前阶段允许且本轮尚未确认时才允许提交。公共版本及幂等键保护,失败回滚确认历史、数量及发运。返回Detail包含canCompletePickup及pickupCompleted。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [recordVersion, idempotencyKey]
properties:
recordVersion: { type: integer, format: int64, minimum: 0 }
idempotencyKey: { type: string, minLength: 8, maxLength: 128 }
responses: { '200': { description: 当前照片与人工取货确认状态 } }
/api/platform/v1/last-leg/dispatches/{number}/looseboard-execution:
parameters:
- { name: number, in: path, required: true, schema: { type: string } }
get:
summary: 读取散板逐票照片与节点资格
description: 需要platform:overseas-warehouse:dispatch:list。data包含dispatchNo、recordVersion、stage、direct和items;items包含itemId、customerId、orderNo、destination、packages、pickupPhotos、arrivalPhotos、canPickup、canArrive。
description: 需要platform:overseas-warehouse:dispatch:list。data包含dispatchNo、recordVersion、stage、direct和items;items包含itemId、customerId、orderNo、destination、packages、pickupPhotos、arrivalPhotos、canPickup、canArrive、warehouseNotice、pickupRequired。pickupRequired仅未入仓且选择上门取货的票为true。warehouseNotice仅送货到仓且尚无入仓实托时返回带原单号的待入仓提示,其他情况为null。
responses: { '200': { description: 统一ApiResponse } }
/api/platform/v1/last-leg/dispatches/{number}/looseboard-execution/{itemId}/{phase}/uploads:
parameters:
@@ -35,6 +35,26 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 主单/资源/占用/账本真实SQL回归;外部目录与选货权威提供者使用受控输入。 */
public class DispatchAggregatePersistenceTest {
/** 取货确认可复用本车释放记录;缺失、已失效或他车重新占用不能被当作本车取货完成。 */
@Test void pickupReleaseAcceptsOnlyOwnReleasedSourceWithoutActiveReassignment() throws Exception {
var f=new Fixture();var selection=f.source(1,"101",501,"ONT8",3);
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pickup-release-create",List.of(selection))));
var row=f.repository.find(created.dispatchNo());var items=f.repository.cargo(row.getId());var at=LocalDateTime.now();
f.tx.executeWithoutResult(s->f.service.releasePickupSources(row,f.actor,items,at));
var released=f.db.queryForObject("SELECT released_at FROM ll_dispatch_pallet_lock WHERE dispatch_order_id=?",LocalDateTime.class,row.getId());
f.tx.executeWithoutResult(s->f.service.releasePickupSources(row,f.actor,items,at.plusSeconds(1)));
assertThat(f.db.queryForObject("SELECT released_at FROM ll_dispatch_pallet_lock WHERE dispatch_order_id=?",LocalDateTime.class,row.getId())).isEqualTo(released);
assertThatThrownBy(()->f.tx.executeWithoutResult(s->f.service.releaseSources(row,f.actor,items,at))).hasMessageContaining("来源占用");
f.db.update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED' WHERE dispatch_order_id=?",row.getId());
assertThatThrownBy(()->f.tx.executeWithoutResult(s->f.service.releasePickupSources(row,f.actor,items,at))).hasMessageContaining("来源占用");
f.db.update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',dispatch_order_id=? WHERE dispatch_order_id=?",row.getId()+1,row.getId());
assertThatThrownBy(()->f.tx.executeWithoutResult(s->f.service.releasePickupSources(row,f.actor,items,at))).hasMessageContaining("来源占用");
f.db.update("UPDATE ll_dispatch_pallet_lock SET dispatch_order_id=?",row.getId());
var item=items.get(0);
f.session.getMapper(DispatchSourceMapper.class).claim(999999L,item.getCustomerId(),item.getSourceKey(),item.getPlanPalletId(),null,null,null,row.getId()+1,"other",at);
assertThatThrownBy(()->f.tx.executeWithoutResult(s->f.service.releasePickupSources(row,f.actor,items,at))).hasMessageContaining("来源占用");
assertThat(f.db.queryForObject("SELECT status FROM ll_dispatch_pallet_lock WHERE id=999999",String.class)).isEqualTo("ACTIVE");
}
/** 正式司机附件从排车冻结引用选出真实司机,再复用司机域及File的读取边界。 */
@Test void masterDriverEvidenceUsesFrozenDriverOwnerForMetadataAndContent() throws Exception {
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
@@ -30,6 +30,42 @@ import static org.mockito.ArgumentMatchers.*;
/** 装车后五框门禁、保存发车共同事务及跨端竞争使用真实单据和版本。 */
class DispatchLoadingDeparturePersistenceTest {
/** 仓内已发运、异地尚未确认取齐时两个页签同时命中,取齐后只保留派送中且总量不重复。 */
@Test void mixedInTransitPendingPickupAppearsInBothTabsWithoutDuplicatingTotal() throws Exception {
var f=new Fixture();var mapper=f.f.session.getMapper(DispatchAggregateMapper.class);
f.f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',departed_at=?,shipped_package_count=4",java.time.LocalDateTime.now());
var after=new DispatchAggregateMapper.Search(null,null,null,null,null,"ACTIVE","AFTER_LOADING",null,null,0,10);
var delivering=new DispatchAggregateMapper.Search(null,null,null,null,null,"ACTIVE","DELIVERING",null,null,0,10);
assertThat(mapper.count(after)).isOne();assertThat(mapper.count(delivering)).isOne();
assertThat(mapper.page(after)).hasSize(1);
assertThat(mapper.summaries(after)).singleElement().satisfies(s->{assertThat(s.quantity()).isOne();assertThat(s.pendingPickups()).isOne();});
f.f.db.update("UPDATE ll_dispatch_order SET shipped_package_count=package_count");
assertThat(mapper.count(after)).isZero();assertThat(mapper.count(delivering)).isOne();
}
/** 混排仓内真实装车后直接派送,装车照片可后补,取货节点不能阻塞仓内货签收。 */
@Test void mixedLoadingCompletionDepartsWithoutWaitingForAllLoadingPhotos() throws Exception {
authorize();var f=new Fixture();var workflow=spy(f.f.service);
doReturn(true).when(workflow).mixedPickup(any());
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"workflow",workflow);
var result=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("mixed-start-delivery")));
assertThat(result.stage()).isEqualTo(Stage.DELIVERING);
assertThat(f.documents.get(f.f.actor,f.number).complete()).isFalse();
assertThat(f.f.db.queryForList("SELECT status FROM ll_delivery_order",String.class)).containsOnly("DELIVERING");
assertThat(f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("mixed-start-delivery"))).replayed()).isTrue();
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());
}
/** 旧混排已装车可按当前版本续接正式派送;过期请求失败且不重复仓内装车。 */
@Test void alreadyLoadedMixedDispatchCanResumeWithFreshVersionAndManifest() throws Exception {
authorize();var f=new Fixture();
var loaded=f.f.tx.execute(s->f.f.service.loadingComplete(f.f.actor,f.number,f.request("mixed-original-load")));
var workflow=spy(f.f.service);doReturn(true).when(workflow).mixedPickup(any());
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"workflow",workflow);
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("mixed-stale")))).hasMessageContaining("版本");
var request=new DispatchCommands.LoadingComplete(loaded.recordVersion(),"mixed-resume-delivery",f.manifest,null);
assertThat(f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,request)).stage()).isEqualTo(Stage.DELIVERING);
verify(workflow,never()).loadingComplete(any(),anyString(),any());
verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
}
/** 绑定必须引用本人的原上传文件及分类,不能用另一张图或另一个操作者的回执充数。 */
@Test void bindingRequiresTheOriginalUploadFileSlotAndActor() throws Exception {
authorize();var f=new Fixture();
@@ -19,6 +19,37 @@ import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class DispatchLooseboardExecutionServiceTest {
/** 装车后仓内货可先送达,异地货独立取送,不要求仓内货重复上传取货凭证。 */
@Test void warehouseArrivalDoesNotWaitForRemotePickupOrOwnPickupPhoto() {
var f=new Fixture(true);f.repository.cargo(1).get(0).setWarehousePalletId(501L);
f.row.setStage("AFTER_LOADING");f.row.setPickedPackageCount(2);
var initial=f.service.get(f.actor,"PC1");
assertThat(initial.items().get(0).canArrive()).isTrue();
assertThat(initial.items().get(0).pickupRequired()).isFalse();
assertThat(initial.items().get(1).pickupRequired()).isTrue();
assertThat(initial.items().get(1).canPickup()).isTrue();
assertThat(initial.items().get(1).canArrive()).isFalse();
f.save(101,Phase.ARRIVAL,1);
assertThat(f.row.getStage()).isEqualTo("AFTER_LOADING");
assertThat(f.facts.complete(f.row,Phase.PICKUP)).isFalse();
f.save(102,Phase.PICKUP,2);
assertThat(f.facts.complete(f.row,Phase.PICKUP)).isTrue();f.confirm();
f.save(102,Phase.ARRIVAL,3);
assertThat(f.facts.complete(f.row,Phase.ARRIVAL)).isTrue();
assertThat(f.service.get(f.actor,"PC1").items().get(0).pickupPhotos()).isEmpty();
assertThat(f.row.getShippedPackageCount()).isEqualTo(2);
verifyNoInteractions(f.completion);
}
/** 待入仓提示仅针对送货到仓且无实托的票,已入仓与上门取货均不误报。 */
@Test void pendingInboundNoticeIdentifiesOnlyUnreceivedWarehouseDelivery() {
var f=new Fixture(true);
when(f.sourceRepo.businessSources(anyList())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",101L,"DELIVERY_TO_WAREHOUSE"),new BusinessSource("B","ZY2","LB2",102L,"WAREHOUSE_PICKUP")));
var items=f.service.get(f.actor,"PC1").items();
assertThat(items.get(0).warehouseNotice()).isEqualTo("LB1:还没拣货入库");
assertThat(items.get(1).warehouseNotice()).isNull();
f.repository.cargo(1).get(0).setWarehousePalletId(501L);
assertThat(f.service.get(f.actor,"PC1").items().get(0).warehouseNotice()).isNull();
}
/** 追加、逐张删除及清空后重传不重复计入取货箱数;删除历史保留但不再授权下载。 */
@Test void editsPhotoListsWithoutDuplicatingRemotePickup() {
var f=new Fixture(true);f.row.setStage("AFTER_LOADING");f.row.setPickedPackageCount(2);
@@ -31,15 +62,15 @@ class DispatchLooseboardExecutionServiceTest {
assertThat(f.facts.current(f.row).get(0).files()).isEmpty();
assertThat(f.facts.complete(f.row,Phase.PICKUP)).isFalse();
f.replace(101,Phase.PICKUP,3L);
assertThat(f.row.getShippedPackageCount()).isEqualTo(2);
assertThat(f.facts.pickedRemotePackages(f.row)).isEqualTo(2);
assertThat(f.row.getShippedPackageCount()).isZero();
assertThat(f.facts.pickedRemotePackages(f.row)).isZero();
assertThat(f.facts.history(1)).hasSize(5);
verify(f.workflow,times(1)).requireRemotePickupCapacity(f.row);
verify(f.orders,times(1)).progress("A","LB1","operator",0);
verify(f.workflow,never()).requireRemotePickupCapacity(f.row);
verifyNoInteractions(f.orders);
}
/** 送达后可替换及追加照片,但最后一张完成凭证不可删,修改不重复触发结案。 */
@Test void completedPhotosCanBeAppendedAndDeletedWhileRetainingEvidence() {
var f=new Fixture(false);f.save(101,Phase.PICKUP,1);f.save(102,Phase.PICKUP,2);
var f=new Fixture(false);f.save(101,Phase.PICKUP,1);f.save(102,Phase.PICKUP,2);f.confirm();
f.save(101,Phase.ARRIVAL,3);
assertThatThrownBy(()->f.replace(101,Phase.PICKUP)).hasMessageContaining("至少保留一张");
f.save(102,Phase.ARRIVAL,4);
@@ -56,7 +87,9 @@ class DispatchLooseboardExecutionServiceTest {
var f=new Fixture(false);var first=f.save(101,Phase.PICKUP,1);
assertThat(first.direct()).isTrue();assertThat(first.stage()).isEqualTo("LOADING");verifyNoInteractions(f.fleet);
var request=new DispatchLooseboardExecutionService.Save(f.row.getRecordVersion(),"pickup-original",List.of(2L));
var departed=f.service.save(f.actor,"PC1",102,Phase.PICKUP,request);
var saved=f.service.save(f.actor,"PC1",102,Phase.PICKUP,request);
assertThat(saved.stage()).isEqualTo("LOADING");assertThat(saved.canCompletePickup()).isTrue();verifyNoInteractions(f.fleet);
var departed=f.confirm();
assertThat(departed.stage()).isEqualTo("DELIVERING");assertThat(f.row.getPickedPackageCount()).isZero();assertThat(f.row.getShippedPackageCount()).isEqualTo(4);
f.service.save(f.actor,"PC1",102,Phase.PICKUP,request);
verify(f.fleet,times(1)).departPhotoDispatchShell(eq("PC1"),eq("operator"),any(),anyString());
@@ -69,27 +102,28 @@ class DispatchLooseboardExecutionServiceTest {
}
/** 混排仍保留原出库链,散板照片不能单独结束其他客户的派送。 */
@Test void mixedCargoOnlyAddsEvidenceAndWakesNormalCompletion() {
var f=new Fixture(true);f.row.setStage("DELIVERING");f.row.setPickedPackageCount(2);f.row.setShippedPackageCount(2);
f.save(101,Phase.PICKUP,1);f.save(102,Phase.PICKUP,2);f.save(101,Phase.ARRIVAL,3);f.save(102,Phase.ARRIVAL,4);
var f=new Fixture(true);f.row.setStage("DELIVERING");f.row.setDepartedAt(LocalDateTime.now());f.row.setPickedPackageCount(2);f.row.setShippedPackageCount(2);
f.save(101,Phase.PICKUP,1);f.save(102,Phase.PICKUP,2);f.confirm();f.save(101,Phase.ARRIVAL,3);f.save(102,Phase.ARRIVAL,4);
assertThat(f.row.getStage()).isEqualTo("DELIVERING");assertThat(f.facts.direct(f.row)).isFalse();
assertThat(f.facts.complete(f.row,Phase.PICKUP)).isTrue();assertThat(f.facts.complete(f.row,Phase.ARRIVAL)).isTrue();
verifyNoInteractions(f.fleet,f.outbounds,f.completion);
verify(f.deliveries).normalDeliveryFacts(any());
assertThat(f.row.getShippedPackageCount()).isEqualTo(6);assertThat(f.row.getPickedPackageCount()).isEqualTo(2);
verify(f.workflow).releasePickupSources(eq(f.row),eq(f.actor),argThat(items->items.size()==2),any());
verify(f.workflow,never()).releaseSources(any(),any(),anyList(),any());
}
/** 仓内货先送达不阻塞后续异地取货;各票散板可交错取送,最后一票送达才整车完结。 */
@Test void mixedWarehouseDeliveryMayFinishBeforeRemotePickupsAndInterleavedArrivals() {
var f=new Fixture(true);f.row.setStage("DELIVERING");f.row.setPickedPackageCount(2);f.row.setShippedPackageCount(2);
var f=new Fixture(true);f.row.setStage("DELIVERING");f.row.setDepartedAt(LocalDateTime.now());f.row.setPickedPackageCount(2);f.row.setShippedPackageCount(2);
when(f.deliveries.normalDeliveryFacts(any())).thenReturn(new NormalDeliveryFacts("PC1","signed",true,Instant.now(),List.of("DL1"),List.of(),List.of()));
var initial=f.service.get(f.actor,"PC1");
assertThat(initial.stage()).isEqualTo("DELIVERING");
assertThat(initial.items()).allMatch(i->i.canPickup() && !i.canArrive());
f.save(101,Phase.PICKUP,1);
f.save(101,Phase.PICKUP,1);f.save(102,Phase.PICKUP,3);f.confirm();
var partial=f.save(101,Phase.ARRIVAL,2);
assertThat(partial.stage()).isEqualTo("DELIVERING");
assertThat(partial.items().stream().filter(i->i.itemId().equals("102")).findFirst().orElseThrow().canPickup()).isTrue();
verifyNoInteractions(f.completion);
f.save(102,Phase.PICKUP,3);
assertThat(f.row.getStage()).isEqualTo("DELIVERING");
f.save(102,Phase.ARRIVAL,4);
assertThat(f.row.getStage()).isEqualTo("DELIVERED");
@@ -105,13 +139,13 @@ class DispatchLooseboardExecutionServiceTest {
assertThat(initial.items()).allMatch(i->i.canPickup() && !i.canArrive());
var picked=f.save(101,Phase.PICKUP,1);
assertThat(picked.stage()).isEqualTo("AFTER_LOADING");
assertThat(picked.items().stream().filter(i->i.itemId().equals("101")).findFirst().orElseThrow().canArrive()).isTrue();
f.save(101,Phase.ARRIVAL,2);f.save(102,Phase.PICKUP,3);f.save(102,Phase.ARRIVAL,4);
assertThat(picked.items().stream().filter(i->i.itemId().equals("101")).findFirst().orElseThrow().canArrive()).isFalse();
f.save(102,Phase.PICKUP,3);f.confirm();f.save(101,Phase.ARRIVAL,2);f.save(102,Phase.ARRIVAL,4);
assertThat(f.row.getStage()).isEqualTo("AFTER_LOADING");
assertThat(f.row.getDepartedAt()).isNull();assertThat(f.row.getDeliveryCompletedAt()).isNull();
assertThat(f.row.getPickedPackageCount()).isEqualTo(2);assertThat(f.row.getShippedPackageCount()).isEqualTo(4);
assertThat(f.facts.pickedRemotePackages(f.row)).isEqualTo(4);
verify(f.workflow,times(2)).requireRemotePickupCapacity(f.row);
verify(f.workflow,times(1)).requireRemotePickupCapacity(f.row);
verifyNoInteractions(f.fleet,f.outbounds,f.deliveries,f.completion);
}
/** 未确认装车的混排不允许提前上传,取消后同样不能上传。 */
@@ -148,22 +182,24 @@ class DispatchLooseboardExecutionServiceTest {
}
static class Fixture {
final ObjectMapper json=new ObjectMapper().findAndRegisterModules();final Actor actor=new Actor("operator",DispatchTypes.ActorMode.PLATFORM,null);
final DispatchAggregate row=new DispatchAggregate();final List<String> history=new ArrayList<>();
final DispatchAggregate row=new DispatchAggregate();final List<String> history=new ArrayList<>();final List<String> confirmations=new ArrayList<>();
final DispatchAggregateRepository repository=mock(DispatchAggregateRepository.class);
final DispatchLooseboardEvidenceRepository evidence=mock(DispatchLooseboardEvidenceRepository.class);
final DispatchAggregateService workflow=mock(DispatchAggregateService.class);final DispatchEvidenceGateway files=mock(DispatchEvidenceGateway.class);
final DispatchOutboundPort outbounds=mock(DispatchOutboundPort.class);final FleetTripExecutionService fleet=mock(FleetTripExecutionService.class);
final FleetTripCompletionService completion=mock(FleetTripCompletionService.class);final DispatchDeliveryPort deliveries=mock(DispatchDeliveryPort.class);
final LooseboardTransportPort orders=mock(LooseboardTransportPort.class);
final LooseboardCargoRepository sourceRepo=mock(LooseboardCargoRepository.class);
final DispatchLooseboardFacts facts;final DispatchLooseboardExecutionService service;
Fixture(boolean mixed){
row.setId(1L);row.setDispatchNo("PC1");row.setStage("LOADING");row.setStatus("ACTIVE");row.setReviewState("APPROVED");row.setRecordVersion(0L);
row.setReviewRound(1L);row.setCargoHash("cargo");row.setPackageCount(mixed?6:4);row.setPickedPackageCount(0);row.setPalletCount(mixed?3:2);row.setShippedPackageCount(0);
var cargo=new ArrayList<>(List.of(item(101,"A","ZY1"),item(102,"B","ZY2")));if(mixed)cargo.add(item(103,"C","OTHER"));
when(repository.find("PC1")).thenReturn(row);when(repository.cargo(1)).thenReturn(cargo);when(repository.saveState(eq(row),anyLong())).thenReturn(1);
when(repository.history(anyLong(),eq(row),anyString(),anyString(),any(),anyString(),any())).thenAnswer(call->{if("LOOSEBOARD_PHOTO".equals(call.getArgument(2)))history.add(call.getArgument(5));return 1;});
when(repository.history(anyLong(),eq(row),anyString(),anyString(),any(),anyString(),any())).thenAnswer(call->{if("LOOSEBOARD_PHOTO".equals(call.getArgument(2)))history.add(call.getArgument(5));if("LOOSEBOARD_PICKUP_COMPLETE".equals(call.getArgument(2)))confirmations.add(call.getArgument(5));return 1;});
when(evidence.pickupConfirmations(1)).thenAnswer(call->List.copyOf(confirmations));when(workflow.warehouseItems(row)).thenAnswer(call->factsWarehouseItems());
when(evidence.snapshots(1)).thenAnswer(call->List.copyOf(history));when(evidence.deliveredPackages(anyString(),anyString())).thenReturn(2L);
var sourceRepo=mock(LooseboardCargoRepository.class);when(sourceRepo.businessSources(anyList())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",101L,"WAREHOUSE_PICKUP"),new BusinessSource("B","ZY2","LB2",102L,"WAREHOUSE_PICKUP")));
when(sourceRepo.businessSources(anyList())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",101L,"WAREHOUSE_PICKUP"),new BusinessSource("B","ZY2","LB2",102L,"WAREHOUSE_PICKUP")));
facts=new DispatchLooseboardFacts(repository,new DispatchLooseboardDisplayService(sourceRepo),evidence,json);
var context=mock(Context.class);var resources=new Resources(new VehicleSnapshot(DispatchTypes.ResourceMode.MASTER,1L,"TEST",null,"IN_SERVICE",new Capacity(10,new java.math.BigDecimal("1000"),new java.math.BigDecimal("50")),List.of()),null,null);
when(context.resources()).thenReturn(resources);when(workflow.preview(actor,"PC1")).thenReturn(context);when(workflow.lockForArrangement(actor,"PC1")).thenReturn(context);
@@ -172,6 +208,8 @@ class DispatchLooseboardExecutionServiceTest {
when(deliveries.normalDeliveryFacts(context)).thenReturn(new NormalDeliveryFacts("PC1","hash",false,null,List.of("DL1"),List.of(new NormalDeliveryFact("A","group","OUT1","DL1",5,6,0,0,0,"DELIVERING",true,null)),List.of()));
service=new DispatchLooseboardExecutionService(workflow,repository,facts,files,outbounds,fleet,completion,deliveries,orders,new DispatchCommandHasher(json),json);
}
List<DispatchItem> factsWarehouseItems(){return facts.warehouseItems(1);}
DispatchLooseboardExecutionService.Detail confirm(){return service.completePickup(actor,"PC1",new DispatchLooseboardExecutionService.Confirm(row.getRecordVersion(),UUID.randomUUID().toString()));}
DispatchLooseboardExecutionService.Detail save(long item,Phase phase,long file){return service.save(actor,"PC1",item,phase,new DispatchLooseboardExecutionService.Save(row.getRecordVersion(),UUID.randomUUID().toString(),List.of(file)));}
DispatchLooseboardExecutionService.Detail replace(long item,Phase phase,Long... files){return service.save(actor,"PC1",item,phase,new DispatchLooseboardExecutionService.Save(row.getRecordVersion(),UUID.randomUUID().toString(),List.of(files)));}
DispatchItem item(long id,String customer,String number){var item=new DispatchItem();item.setId(id);item.setCustomerId(customer);item.setOrderNo(number);item.setPlanPalletId(id);item.setPackageCount(2);item.setDeliveryAddress("TEST DESTINATION");return item;}
@@ -36,12 +36,15 @@ class DispatchLooseboardPhotoPersistenceTest {
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(f.transactions,new AnnotationTransactionAttributeSource()));
var service=(DispatchLooseboardExecutionService)proxy.getProxy();
var request=new DispatchLooseboardExecutionService.Save(root.getRecordVersion(),"pickup-rollback-original",List.of(77L));
service.save(f.actor,number,item.getId(),DispatchLooseboardFacts.Phase.PICKUP,request);
var savedVersion=f.repository.find(number).getRecordVersion();
var confirm=new DispatchLooseboardExecutionService.Confirm(savedVersion,"pickup-explicit-confirm");
doThrow(new IllegalStateException("模拟发车故障")).when(fleet).departPhotoDispatchShell(eq(number),anyString(),any(),anyString());
assertThatThrownBy(()->service.save(f.actor,number,item.getId(),DispatchLooseboardFacts.Phase.PICKUP,request)).hasMessageContaining("模拟发车故障");
assertThat(evidence.snapshots(root.getId())).isEmpty();assertThat(f.repository.find(number).getRecordVersion()).isEqualTo(root.getRecordVersion());
assertThatThrownBy(()->service.completePickup(f.actor,number,confirm)).hasMessageContaining("模拟发车故障");
assertThat(evidence.snapshots(root.getId())).hasSize(1);assertThat(evidence.pickupConfirmations(root.getId())).isEmpty();assertThat(f.repository.find(number).getRecordVersion()).isEqualTo(savedVersion);
assertThat(f.repository.find(number).getStage()).isEqualTo("LOADING");assertThat(f.repository.find(number).getShippedPackageCount()).isZero();
doNothing().when(fleet).departPhotoDispatchShell(eq(number),anyString(),any(),anyString());
assertThat(service.save(f.actor,number,item.getId(),DispatchLooseboardFacts.Phase.PICKUP,request).stage()).isEqualTo("DELIVERING");
assertThat(service.completePickup(f.actor,number,confirm).stage()).isEqualTo("DELIVERING");
assertThat(evidence.snapshots(root.getId())).hasSize(1);assertThat(f.repository.find(number).getPickedPackageCount()).isZero();
service.save(f.actor,number,item.getId(),DispatchLooseboardFacts.Phase.PICKUP,request);
assertThat(evidence.snapshots(root.getId())).hasSize(1);