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')") @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));} 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) @PostMapping(value="/{itemId}/{phase}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
@@ -110,7 +110,7 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
var row=required(number,lock); 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(!"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()) { 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); && 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(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(),"混排散板逐票取货和送达照片尚未齐全"); 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_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 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 = """ String CURRENT_FILTER = """
<if test='q.status != "ALL"'> AND d.status=#{q.status}</if> <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 == "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 == "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> <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, record Search(String dispatchNo, String vehiclePlate, String warehouseId, String customerId,
String fbaAppointmentNo, String status, String stage, String section, String reviewStatus, String fbaAppointmentNo, String status, String stage, String section, String reviewStatus,
long offset, int limit) { } 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。 */ /** 首次创建整车所有权及初始版本,不写customer_id。 */
@Insert(""" @Insert("""
@@ -95,7 +102,7 @@ public interface DispatchAggregateMapper {
long count(@Param("q") Search query); long count(@Param("q") Search query);
/** 基集统计不套当前阶段和审核筛选,客户通过明细EXISTS去重。 */ /** 基集统计不套当前阶段和审核筛选,客户通过明细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>") + BASE_FILTER + " GROUP BY d.status,d.stage,d.review_state</script>")
List<Summary> summaries(@Param("q") Search query); 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") @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); 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("""
SELECT COALESCE(SUM(done.packages),0) FROM ( 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'") @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); 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") @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); 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 DispatchLooseboardEvidenceRepository(DispatchLooseboardEvidenceMapper mapper){this.mapper=mapper;}
/** 历史始终按公共版本递增返回。 */ /** 历史始终按公共版本递增返回。 */
public List<String> snapshots(long id){return mapper.snapshots(id);} 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);} 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 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 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);} 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) @Transactional(propagation=Propagation.MANDATORY)
public void releaseSources(DispatchAggregate row,Actor actor,List<DispatchItem> items,LocalDateTime at) { 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 owner=new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,actor.operatorId(),null);
var original=physicalClaims.lockOwner(owner); 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); 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) { for(var group:groups) {
if(Status.CANCELLED.name().equals(group.status())) { cancelled+=group.quantity(); continue; } 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); 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) { if(stage==Stage.BEFORE_LOADING) {
// 三分区表示当前待办,驳回只能计入客服处理,不能留在审核队列。 // 三分区表示当前待办,驳回只能计入客服处理,不能留在审核队列。
var review=ReviewState.valueOf(group.reviewState()); 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()); } 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 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);} 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("本车尚无合法在途运输事实"); 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 changed=false;var now=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);
boolean photosReady=looseboardFacts==null || looseboardFacts.cargo(row.getId()).isEmpty() 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; boolean complete=facts.allNormallySigned() && photosReady;
if(Stage.DELIVERING.name().equals(row.getStage()) && complete) { if(Stage.DELIVERING.name().equals(row.getStage()) && complete) {
if(facts.completedAt()==null || facts.factsHash()==null || facts.factsHash().isBlank() || row.getDepartedAt()==null)throw new IllegalStateException("缺少正常签收完成依据"); 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(), var identity=new CommandIdentity(Action.LOADING_COMPLETE,request.idempotencyKey(),request.recordVersion(),
hashes.command(actor,Action.LOADING_COMPLETE,number,request.recordVersion(),request)); hashes.command(actor,Action.LOADING_COMPLETE,number,request.recordVersion(),request));
var original=workflow.replay(context,actor,identity);if(original.isPresent())return original.get(); 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())); 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 finalState=ready?departAfterDocuments(actor,number,request.idempotencyKey()):loaded;
var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),finalState.recordVersion(), 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); commands.append(context.dispatchId(),actor,identity,result);
return result; return result;
} }
@@ -60,7 +67,7 @@ public class DispatchLoadingDepartureService {
hashes.source(List.of(number,actor.operatorId(),context.recordVersion(),fact.arrangementBusinessHash()))); hashes.source(List.of(number,actor.operatorId(),context.recordVersion(),fact.arrangementBusinessHash())));
var arranged=workflow.recordArrangement(context,actor,arrangementIdentity,fact,false); var arranged=workflow.recordArrangement(context,actor,arrangementIdentity,fact,false);
var departed=departure.depart(actor,number,new DispatchCommands.Depart(arranged.recordVersion(), 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) if(departed.stage()!=Stage.DELIVERING)
throw new BizException(ResultCode.CONFLICT,"装车发运结果未核平"); throw new BizException(ResultCode.CONFLICT,"装车发运结果未核平");
return departed; return departed;
@@ -25,9 +25,10 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
public class DispatchLooseboardExecutionService { public class DispatchLooseboardExecutionService {
public record Save(@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey, 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) {} @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, public record Item(String itemId,String customerId,String orderNo,String destination,int packages,List<File> pickupPhotos,
List<File> arrivalPhotos,boolean canPickup,boolean canArrive) {} 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) {} 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) {} public record Download(File file,byte[] bytes) {}
private final DispatchAggregateService workflow; private final DispatchAggregateService workflow;
private final DispatchAggregateRepository repository; 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张不重复照片"); 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(); var previous=phase==Phase.PICKUP?item.pickupPhotos():item.arrivalPhotos();
if(request.fileIds().isEmpty() && previous.isEmpty())throw conflict("请先选择照片"); 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()); 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 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()); 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)); 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.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(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(phase==Phase.ARRIVAL && !photos.isEmpty()){
if(!arrivalRecorded)orders.progress(cargo.item().getCustomerId(),cargo.orderNo(),actor.operatorId(),facts.deliveredPackages(cargo.item().getCustomerId(),cargo.orderNo())); 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)){ 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)); var normal=deliveries.normalDeliveryFacts(workflow.preview(actor,number));
if(normal.allNormallySigned()){ if(normal.allNormallySigned()){
transition(row,"DELIVERED",at,actor,"混排全部货物签收且散板取货、送达照片齐全"); transition(row,"DELIVERED",at,actor,"混排全部货物签收且散板取货、送达照片齐全");
@@ -111,6 +104,32 @@ public class DispatchLooseboardExecutionService {
} }
return detail(row); 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){ 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("散板明细不存在")); 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){ private Detail detail(DispatchAggregate row){
boolean direct=facts.direct(row);var evidence=facts.current(row);boolean active="ACTIVE".equals(row.getStatus()); 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 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 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(); 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 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 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(); 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); 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();return new Detail(row.getDispatchNo(),row.getRecordVersion(),row.getStage(),direct,items); }).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;} 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()));} 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); 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){ 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) 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 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())) .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();} .mapToLong(c->c.item().getPackageCount()).sum();}
/** 命令历史只读,JSON损坏明确失败,不静默当作照片缺失。 */ /** 命令历史只读,JSON损坏明确失败,不静默当作照片缺失。 */
public List<Evidence> history(long id){return repository.snapshots(id).stream().map(value->{ 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);} try{return json.readValue(value,Evidence.class);}catch(Exception error){throw new IllegalStateException("散板照片履约历史读取失败",error);}
}).toList();} }).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){ public List<Evidence> current(DispatchAggregate row){
var latest=new LinkedHashMap<String,Evidence>(); var latest=new LinkedHashMap<String,Evidence>();
@@ -60,10 +73,11 @@ public class DispatchLooseboardFacts {
.forEach(e->latest.put(e.itemId()+":"+e.phase(),e)); .forEach(e->latest.put(e.itemId()+":"+e.phase(),e));
return List.copyOf(latest.values()); return List.copyOf(latest.values());
} }
/** 每一票都须有自己的有效照片,不能用全车总数代替。 */ /** 仓内实托走原出库签收链,照片节点只核对尚待上门取货的散板,不跨票替代。 */
public boolean complete(DispatchAggregate row,Phase phase){ public boolean complete(DispatchAggregate row,Phase phase){
var cargo=cargo(row.getId());var evidence=current(row); 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());} 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 version: 1.0.0
description: >- description: >-
仅同客户权威关联的本土散板明细进入照片节点。全车为散板且尚无真实扫码时,审核通过后逐票保存取货照片, 仅同客户权威关联的本土散板明细进入照片节点。全车为散板且尚无真实扫码时,审核通过后逐票保存取货照片,
自动从LOADING进入DELIVERING;不生成库存、PDA拣货或仓库出库事实。逐票保存送达照片后自动DELIVERED并结案释放车辆司机。 全部齐全才显示取货完成按钮;显式确认后从LOADING进入DELIVERING。不生成库存、PDA拣货或仓库出库事实。逐票保存送达照片后自动DELIVERED并结案释放车辆司机。
混排从AFTER_LOADING或DELIVERING可逐票保存取货及送达照片;同票须先取货再送达,不同票不限制取送顺序。 混排从AFTER_LOADING或DELIVERING可逐票保存取货及送达照片;异地货同票须先取货再送达,不同票不限制取送顺序。
装车后保存照片保持原阶段,保留原PDA出库及装车资料齐全后进入派送的链路;全部仓内货签收且散板照片齐全才可整车完结。既有真实扫码不可抹除或倒改。 仓内实托走原出库签收链,不要求散板节点照片;页面按pickupRequired仅展示本趟上门取货货物,送货到仓尚未入库则保留warehouseNotice提示。
混排仓内真实装车确认后即发运仓内货,不等待异地取货或五类装车照片;剩余装车资料可以补传。
仓内已发运而异地货尚未确认取齐时,同时展示在装车后和派送中,两页签计数允许重叠但总数不重复。
取货照片齐全不自动完成取货;点击取货完成后仅在派送中展示,异地货量只在该确认动作计入。全部仓内货签收且散板确认取货并送达照片齐全才可整车完结。
图片只接受10MiB以内JPG或PNG,每票每节点最多20张。保存提交该节点完整保留列表,支持追加和逐张删除;空列表代表删除最后一张。 图片只接受10MiB以内JPG或PNG,每票每节点最多20张。保存提交该节点完整保留列表,支持追加和逐张删除;空列表代表删除最后一张。
整车已完结的节点及已有送达照片的取货节点至少保留一张凭证,可先追加再删除;修改照片不撤销已执行的取货、发运或送达,不重复累计货量。 整车已完结的节点及已有送达照片的取货节点至少保留一张凭证,可先追加再删除;修改照片不撤销已执行的取货、发运或送达,不重复累计货量。
上传不等于保存,保存用公共版本和幂等键,失败同事务回滚。历史照片列表保留审计,仅最新列表授权预览。 上传不等于保存,保存用公共版本和幂等键,失败同事务回滚。历史照片列表保留审计,仅最新列表授权预览。
本土散板号只用LB原单,不生成入仓码、柜号或虚构派送单号。分车运输按已保存送达照片的明细去重累计箱数,整票送达后才完成原单。 本土散板号只用LB原单,不生成入仓码、柜号或虚构派送单号。分车运输按已保存送达照片的明细去重累计箱数,整票送达后才完成原单。
paths: 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: /api/platform/v1/last-leg/dispatches/{number}/looseboard-execution:
parameters: parameters:
- { name: number, in: path, required: true, schema: { type: string } } - { name: number, in: path, required: true, schema: { type: string } }
get: get:
summary: 读取散板逐票照片与节点资格 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 } } responses: { '200': { description: 统一ApiResponse } }
/api/platform/v1/last-leg/dispatches/{number}/looseboard-execution/{itemId}/{phase}/uploads: /api/platform/v1/last-leg/dispatches/{number}/looseboard-execution/{itemId}/{phase}/uploads:
parameters: parameters:
@@ -35,6 +35,26 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 主单/资源/占用/账本真实SQL回归;外部目录与选货权威提供者使用受控输入。 */ /** 主单/资源/占用/账本真实SQL回归;外部目录与选货权威提供者使用受控输入。 */
public class DispatchAggregatePersistenceTest { 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的读取边界。 */ /** 正式司机附件从排车冻结引用选出真实司机,再复用司机域及File的读取边界。 */
@Test void masterDriverEvidenceUsesFrozenDriverOwnerForMetadataAndContent() throws Exception { @Test void masterDriverEvidenceUsesFrozenDriverOwnerForMetadataAndContent() throws Exception {
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L)); 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 { 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 { @Test void bindingRequiresTheOriginalUploadFileSlotAndActor() throws Exception {
authorize();var f=new Fixture(); authorize();var f=new Fixture();
@@ -19,6 +19,37 @@ import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.*;
class DispatchLooseboardExecutionServiceTest { 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() { @Test void editsPhotoListsWithoutDuplicatingRemotePickup() {
var f=new Fixture(true);f.row.setStage("AFTER_LOADING");f.row.setPickedPackageCount(2); 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.current(f.row).get(0).files()).isEmpty();
assertThat(f.facts.complete(f.row,Phase.PICKUP)).isFalse(); assertThat(f.facts.complete(f.row,Phase.PICKUP)).isFalse();
f.replace(101,Phase.PICKUP,3L); f.replace(101,Phase.PICKUP,3L);
assertThat(f.row.getShippedPackageCount()).isEqualTo(2); assertThat(f.row.getShippedPackageCount()).isZero();
assertThat(f.facts.pickedRemotePackages(f.row)).isEqualTo(2); assertThat(f.facts.pickedRemotePackages(f.row)).isZero();
assertThat(f.facts.history(1)).hasSize(5); assertThat(f.facts.history(1)).hasSize(5);
verify(f.workflow,times(1)).requireRemotePickupCapacity(f.row); verify(f.workflow,never()).requireRemotePickupCapacity(f.row);
verify(f.orders,times(1)).progress("A","LB1","operator",0); verifyNoInteractions(f.orders);
} }
/** 送达后可替换及追加照片,但最后一张完成凭证不可删,修改不重复触发结案。 */ /** 送达后可替换及追加照片,但最后一张完成凭证不可删,修改不重复触发结案。 */
@Test void completedPhotosCanBeAppendedAndDeletedWhileRetainingEvidence() { @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); f.save(101,Phase.ARRIVAL,3);
assertThatThrownBy(()->f.replace(101,Phase.PICKUP)).hasMessageContaining("至少保留一张"); assertThatThrownBy(()->f.replace(101,Phase.PICKUP)).hasMessageContaining("至少保留一张");
f.save(102,Phase.ARRIVAL,4); 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); 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); 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 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); 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); f.service.save(f.actor,"PC1",102,Phase.PICKUP,request);
verify(f.fleet,times(1)).departPhotoDispatchShell(eq("PC1"),eq("operator"),any(),anyString()); verify(f.fleet,times(1)).departPhotoDispatchShell(eq("PC1"),eq("operator"),any(),anyString());
@@ -69,27 +102,28 @@ class DispatchLooseboardExecutionServiceTest {
} }
/** 混排仍保留原出库链,散板照片不能单独结束其他客户的派送。 */ /** 混排仍保留原出库链,散板照片不能单独结束其他客户的派送。 */
@Test void mixedCargoOnlyAddsEvidenceAndWakesNormalCompletion() { @Test void mixedCargoOnlyAddsEvidenceAndWakesNormalCompletion() {
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);
f.save(101,Phase.PICKUP,1);f.save(102,Phase.PICKUP,2);f.save(101,Phase.ARRIVAL,3);f.save(102,Phase.ARRIVAL,4); 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.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(); 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); verifyNoInteractions(f.fleet,f.outbounds,f.completion);
verify(f.deliveries).normalDeliveryFacts(any()); verify(f.deliveries).normalDeliveryFacts(any());
assertThat(f.row.getShippedPackageCount()).isEqualTo(6);assertThat(f.row.getPickedPackageCount()).isEqualTo(2); 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() { @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())); 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"); var initial=f.service.get(f.actor,"PC1");
assertThat(initial.stage()).isEqualTo("DELIVERING"); assertThat(initial.stage()).isEqualTo("DELIVERING");
assertThat(initial.items()).allMatch(i->i.canPickup() && !i.canArrive()); 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); var partial=f.save(101,Phase.ARRIVAL,2);
assertThat(partial.stage()).isEqualTo("DELIVERING"); assertThat(partial.stage()).isEqualTo("DELIVERING");
assertThat(partial.items().stream().filter(i->i.itemId().equals("102")).findFirst().orElseThrow().canPickup()).isTrue(); assertThat(partial.items().stream().filter(i->i.itemId().equals("102")).findFirst().orElseThrow().canPickup()).isTrue();
verifyNoInteractions(f.completion); verifyNoInteractions(f.completion);
f.save(102,Phase.PICKUP,3);
assertThat(f.row.getStage()).isEqualTo("DELIVERING"); assertThat(f.row.getStage()).isEqualTo("DELIVERING");
f.save(102,Phase.ARRIVAL,4); f.save(102,Phase.ARRIVAL,4);
assertThat(f.row.getStage()).isEqualTo("DELIVERED"); assertThat(f.row.getStage()).isEqualTo("DELIVERED");
@@ -105,13 +139,13 @@ class DispatchLooseboardExecutionServiceTest {
assertThat(initial.items()).allMatch(i->i.canPickup() && !i.canArrive()); assertThat(initial.items()).allMatch(i->i.canPickup() && !i.canArrive());
var picked=f.save(101,Phase.PICKUP,1); var picked=f.save(101,Phase.PICKUP,1);
assertThat(picked.stage()).isEqualTo("AFTER_LOADING"); assertThat(picked.stage()).isEqualTo("AFTER_LOADING");
assertThat(picked.items().stream().filter(i->i.itemId().equals("101")).findFirst().orElseThrow().canArrive()).isTrue(); assertThat(picked.items().stream().filter(i->i.itemId().equals("101")).findFirst().orElseThrow().canArrive()).isFalse();
f.save(101,Phase.ARRIVAL,2);f.save(102,Phase.PICKUP,3);f.save(102,Phase.ARRIVAL,4); 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.getStage()).isEqualTo("AFTER_LOADING");
assertThat(f.row.getDepartedAt()).isNull();assertThat(f.row.getDeliveryCompletedAt()).isNull(); assertThat(f.row.getDepartedAt()).isNull();assertThat(f.row.getDeliveryCompletedAt()).isNull();
assertThat(f.row.getPickedPackageCount()).isEqualTo(2);assertThat(f.row.getShippedPackageCount()).isEqualTo(4); assertThat(f.row.getPickedPackageCount()).isEqualTo(2);assertThat(f.row.getShippedPackageCount()).isEqualTo(4);
assertThat(f.facts.pickedRemotePackages(f.row)).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); verifyNoInteractions(f.fleet,f.outbounds,f.deliveries,f.completion);
} }
/** 未确认装车的混排不允许提前上传,取消后同样不能上传。 */ /** 未确认装车的混排不允许提前上传,取消后同样不能上传。 */
@@ -148,22 +182,24 @@ class DispatchLooseboardExecutionServiceTest {
} }
static class Fixture { static class Fixture {
final ObjectMapper json=new ObjectMapper().findAndRegisterModules();final Actor actor=new Actor("operator",DispatchTypes.ActorMode.PLATFORM,null); 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 DispatchAggregateRepository repository=mock(DispatchAggregateRepository.class);
final DispatchLooseboardEvidenceRepository evidence=mock(DispatchLooseboardEvidenceRepository.class); final DispatchLooseboardEvidenceRepository evidence=mock(DispatchLooseboardEvidenceRepository.class);
final DispatchAggregateService workflow=mock(DispatchAggregateService.class);final DispatchEvidenceGateway files=mock(DispatchEvidenceGateway.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 DispatchOutboundPort outbounds=mock(DispatchOutboundPort.class);final FleetTripExecutionService fleet=mock(FleetTripExecutionService.class);
final FleetTripCompletionService completion=mock(FleetTripCompletionService.class);final DispatchDeliveryPort deliveries=mock(DispatchDeliveryPort.class); final FleetTripCompletionService completion=mock(FleetTripCompletionService.class);final DispatchDeliveryPort deliveries=mock(DispatchDeliveryPort.class);
final LooseboardTransportPort orders=mock(LooseboardTransportPort.class); final LooseboardTransportPort orders=mock(LooseboardTransportPort.class);
final LooseboardCargoRepository sourceRepo=mock(LooseboardCargoRepository.class);
final DispatchLooseboardFacts facts;final DispatchLooseboardExecutionService service; final DispatchLooseboardFacts facts;final DispatchLooseboardExecutionService service;
Fixture(boolean mixed){ Fixture(boolean mixed){
row.setId(1L);row.setDispatchNo("PC1");row.setStage("LOADING");row.setStatus("ACTIVE");row.setReviewState("APPROVED");row.setRecordVersion(0L); 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); 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")); 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.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); 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); 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); 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); 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())); 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); 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 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)));} 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;} 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 proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(f.transactions,new AnnotationTransactionAttributeSource()));
var service=(DispatchLooseboardExecutionService)proxy.getProxy(); var service=(DispatchLooseboardExecutionService)proxy.getProxy();
var request=new DispatchLooseboardExecutionService.Save(root.getRecordVersion(),"pickup-rollback-original",List.of(77L)); 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()); doThrow(new IllegalStateException("模拟发车故障")).when(fleet).departPhotoDispatchShell(eq(number),anyString(),any(),anyString());
assertThatThrownBy(()->service.save(f.actor,number,item.getId(),DispatchLooseboardFacts.Phase.PICKUP,request)).hasMessageContaining("模拟发车故障"); assertThatThrownBy(()->service.completePickup(f.actor,number,confirm)).hasMessageContaining("模拟发车故障");
assertThat(evidence.snapshots(root.getId())).isEmpty();assertThat(f.repository.find(number).getRecordVersion()).isEqualTo(root.getRecordVersion()); 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(); assertThat(f.repository.find(number).getStage()).isEqualTo("LOADING");assertThat(f.repository.find(number).getShippedPackageCount()).isZero();
doNothing().when(fleet).departPhotoDispatchShell(eq(number),anyString(),any(),anyString()); 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(); 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); service.save(f.actor,number,item.getId(),DispatchLooseboardFacts.Phase.PICKUP,request);
assertThat(evidence.snapshots(root.getId())).hasSize(1); assertThat(evidence.snapshots(root.getId())).hasSize(1);