优化
This commit is contained in:
-19
@@ -37,25 +37,6 @@ public class DeliveryTransferController {
|
||||
return ApiResponse.success(dispatchDelivery.preview(actor(user),dispatchNo).groups());
|
||||
}
|
||||
|
||||
/** 一次快照提供全部客户目的地、费用证据及既有安排的完整数组。 */
|
||||
@GetMapping("/dispatches/{dispatchNo}/delivery-arrangement")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
|
||||
public ApiResponse<ArrangementPreview> arrangement(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(dispatchDelivery.preview(actor(user),dispatchNo));
|
||||
}
|
||||
|
||||
/** 平台一次安排本车全部客户,身份只从认证上下文获取。 */
|
||||
@PostMapping("/dispatches/{dispatchNo}/transfer-delivery")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_TRANSFER_DELIVERY + "')")
|
||||
@Operation(summary = "按真实客户生成出库单并按客户目的地生成派送单")
|
||||
public ApiResponse<CommandResult> transfer(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo,
|
||||
@Valid @RequestBody ArrangeDelivery request) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(dispatchDelivery.arrange(actor(user),dispatchNo,request));
|
||||
}
|
||||
|
||||
@PostMapping("/outbound-orders/one-click-dropship")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
|
||||
@Operation(summary = "Create one-click dropship outbound and push a PDA picking task")
|
||||
|
||||
+48
-3
@@ -126,6 +126,51 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
|
||||
var input=inputs.get(group.key());var coverage=fees.coverage(group,input,actor.operatorId(),external.get(group.customerId()));
|
||||
fees.validate(group,input,coverage,external.get(group.customerId()));coverages.put(group.key(),coverage);
|
||||
}
|
||||
var fact=createDocuments(context,actor,request.idempotencyKey(),businessHash,groups,inputs,external,coverages);
|
||||
return execution.recordArrangement(context,actor,identity,fact,false);
|
||||
}
|
||||
|
||||
/** 装车父事务内自动生成全车单据;未知派送资料和未确认费用保持未登记。 */
|
||||
@Override
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public ArrangementFact prepareForDeparture(Context context,Actor actor,String commandKey) {
|
||||
if(actor==null || blank(actor.operatorId()) || blank(commandKey) || commandKey.length()>255)
|
||||
throw conflict("缺少装车操作身份或原请求标识");
|
||||
if(context==null || context.status()!=Status.ACTIVE || context.stage()!=Stage.AFTER_LOADING
|
||||
|| !context.pickingReconciled() || context.loadingCompletedAt()==null || blank(context.loadingCompletionHash())
|
||||
|| blank(context.cargoHash()) || context.cargo()==null || context.cargo().isEmpty())
|
||||
throw conflict("请先完成真实拣货核平及装车确认");
|
||||
if(actor.mode()==ActorMode.PDA && !Objects.equals(actor.boundWarehouseId(),context.warehouseId()))
|
||||
throw conflict("排车不属于当前绑定仓");
|
||||
var saved=repository.arrangement(context.dispatchId(),true);
|
||||
if(saved!=null) {
|
||||
if(!Objects.equals(saved.getOriginalCommandKey(),commandKey)
|
||||
|| !Objects.equals(saved.getCargoHash(),context.cargoHash())
|
||||
|| !Objects.equals(saved.getLoadingCompletionHash(),context.loadingCompletionHash()))
|
||||
throw conflict("本车已有不同装车命令生成的派送事实");
|
||||
var facts=departureFacts(context,true);
|
||||
if(!facts.depart().allowed())throw conflict(facts.depart().blockReason());
|
||||
return fact(saved);
|
||||
}
|
||||
// 不能把缺少安排事实的孤立单据补建成另一套;全部客户在同一事务中创建。
|
||||
if(!repository.outbounds(context.dispatchId(),true).isEmpty() || !repository.deliveries(context.dispatchId(),true).isEmpty())
|
||||
throw conflict("已存在关联单据但缺少完整安排事实,请核对");
|
||||
var groups=grouping.groups(context);var external=fees.external(context);
|
||||
var inputs=new HashMap<String,ArrangeGroup>();
|
||||
for(var group:groups) {
|
||||
String carrier=context.resources()==null || context.resources().vehicle()==null?null:context.resources().vehicle().carrierName();
|
||||
inputs.put(group.key(),new ArrangeGroup(group.key(),group.cargoHash(),null,null,null,carrier,null,null,
|
||||
null,null,null,null,null,external.get(group.customerId()).snapshotHash(),null,List.of(),List.of()));
|
||||
}
|
||||
String businessHash=grouping.hash(Arrays.asList(context.dispatchNo(),context.cargoHash(),context.loadingCompletionHash(),
|
||||
groups.stream().map(g->inputs.get(g.key())).toList()));
|
||||
return createDocuments(context,actor,commandKey,businessHash,groups,inputs,external,Map.of());
|
||||
}
|
||||
|
||||
/** 自动装车与内部安排复用同一建单、来源锁及真实外接报价发布路径,不推定原费覆盖。 */
|
||||
private ArrangementFact createDocuments(Context context,Actor actor,String commandKey,String businessHash,List<Group> groups,
|
||||
Map<String,ArrangeGroup> inputs,Map<String,com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview> external,
|
||||
Map<String,DeliveryFeeCoverageModels.Evidence> coverages) {
|
||||
var now=Instant.now();String key="DLA"+IdWorker.getId();var outbounds=new LinkedHashMap<String,OutboundOrder>();
|
||||
for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) {
|
||||
var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList();
|
||||
@@ -169,9 +214,9 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
|
||||
var arrangement=new DispatchDeliveryArrangement();arrangement.setId(IdWorker.getId());arrangement.setDispatchOrderId(context.dispatchId());
|
||||
arrangement.setDispatchNo(context.dispatchNo());arrangement.setArrangementKey(key);arrangement.setBusinessHash(businessHash);
|
||||
arrangement.setCargoHash(context.cargoHash());arrangement.setLoadingCompletionHash(context.loadingCompletionHash());
|
||||
arrangement.setOriginalCommandKey(request.idempotencyKey());arrangement.setOriginalOperatorId(actor.operatorId());
|
||||
arrangement.setOriginalCommandKey(commandKey);arrangement.setOriginalOperatorId(actor.operatorId());
|
||||
arrangement.setResultJson(write(result));arrangement.setCargoJson(write(context.cargo()));arrangement.setCreatedAt(local(now));repository.create(arrangement);
|
||||
return execution.recordArrangement(context,actor,identity,fact(arrangement),false);
|
||||
return fact(arrangement);
|
||||
}
|
||||
|
||||
/** 发车先取得全部出库锁,再取得全部派送锁,当前读取不申请来源锁。 */
|
||||
@@ -281,7 +326,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
|
||||
var destination=group.destination();d.setDeliveryGroupKey(grouping.documentGroupKey(destination));d.setDestinationType(destination.type());
|
||||
d.setDestinationWarehouseId(destination.warehouseId());d.setDestinationWarehouseCode(destination.warehouseCode());d.setDestinationWarehouseName(destination.warehouseName());
|
||||
d.setDeliveryAddress(destination.address());d.setPostalCode(destination.postalCode());d.setContactName(destination.contactName());d.setContactPhone(destination.contactPhone());
|
||||
d.setStatus("WAIT_DELIVERY");d.setVersion(0L);d.setDeliveryMethod(input.deliveryMethod());d.setPlannedDeliveryAt(local(input.plannedDeliveryAt()));
|
||||
d.setStatus("WAIT_DELIVERY");d.setVersion(0L);d.setDeliveryMethod(input.deliveryMethod());d.setPlannedDeliveryAt(input.plannedDeliveryAt()==null?null:local(input.plannedDeliveryAt()));
|
||||
d.setWaybillNo(input.waybillNo());d.setCarrierName(input.carrierName());d.setPickupMethod(input.pickupMethod());d.setLoadingType(input.loadingType());
|
||||
d.setVehicleType(input.vehicleType());d.setVehicleSize(input.vehicleSize());d.setAppointmentAccount(input.appointmentAccount());d.setSellerName(input.sellerName());d.setRemark(input.remark());
|
||||
if(context.resources()!=null){if(context.resources().vehicle()!=null)d.setVehiclePlate(context.resources().vehicle().plate());
|
||||
|
||||
+3
-9
@@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
@RequestMapping("/api/platform/v1/last-leg/dispatches")
|
||||
public class DispatchAggregateController {
|
||||
private final DispatchAggregateService service;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchDepartureService departure;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService loadingDeparture;
|
||||
|
||||
/** 注入本域应用服务。 */
|
||||
public DispatchAggregateController(DispatchAggregateService service) { this.service=service; }
|
||||
@@ -71,17 +71,11 @@ public class DispatchAggregateController {
|
||||
public ApiResponse<com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.LoadingPreview> loading(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
|
||||
return ApiResponse.success(service.loading(DispatchActorFactory.platform(user),dispatchNo));
|
||||
}
|
||||
/** 显式确认装车,不复用最后扫码或实际发车操作。 */
|
||||
/** 显式确认装车后自动准备派送并发车,原请求恢复最终派送中结果。 */
|
||||
@PostMapping("/{dispatchNo}/loading-complete")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:loading-complete')")
|
||||
public ApiResponse<CommandResult> loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) {
|
||||
return ApiResponse.success(service.loadingComplete(DispatchActorFactory.platform(user),dispatchNo,request));
|
||||
}
|
||||
/** 与PDA共用实际发车服务和公共版本,未完整安排派送不能发车。 */
|
||||
@PostMapping("/{dispatchNo}/depart")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:depart')")
|
||||
public ApiResponse<CommandResult> depart(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.Depart request) {
|
||||
return ApiResponse.success(departure.depart(DispatchActorFactory.platform(user),dispatchNo,request));
|
||||
return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request));
|
||||
}
|
||||
/** 本车证据先上传,主单引用随后走版本化编辑。 */
|
||||
@PostMapping(value="/{dispatchNo}/evidence/{kind}",consumes=org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
|
||||
+6
-2
@@ -26,6 +26,9 @@ public interface DispatchAggregateMapper {
|
||||
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.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>
|
||||
<if test='q.reviewStatus != null and q.section == "DISPATCH_REVIEW"'> AND (
|
||||
""" + REVIEW_DISPATCH + ")=#{q.reviewStatus}</if>"
|
||||
+ "<if test='q.reviewStatus != null and q.section == \"LOADING_REVIEW\"'> AND (" + REVIEW_LOADING + ")=#{q.reviewStatus}</if>";
|
||||
@@ -76,14 +79,15 @@ public interface DispatchAggregateMapper {
|
||||
@Update("""
|
||||
UPDATE ll_dispatch_order SET cargo_revision=cargo_revision+1,cargo_hash=#{hash},item_count=#{items},
|
||||
pallet_count=#{pallets},package_count=#{packages},route=#{route},review_state='DRAFT',
|
||||
review_basis_hash=NULL,dispatch_review_json=#{pending},loading_review_json=#{pending},
|
||||
review_basis_hash=NULL,dispatch_review_json=#{dispatchReview},loading_review_json=#{loadingReview},
|
||||
record_version=record_version+1,updated_at=#{at}
|
||||
WHERE id=#{id} AND record_version=#{version} AND status='ACTIVE' AND stage='BEFORE_LOADING'
|
||||
AND picked_package_count=0 AND shipped_package_count=0
|
||||
""")
|
||||
int changeCargo(@Param("id") long id, @Param("version") long version, @Param("hash") String hash,
|
||||
@Param("items") int items, @Param("pallets") int pallets, @Param("packages") int packages,
|
||||
@Param("route") String route, @Param("pending") String pending, @Param("at") java.time.LocalDateTime at);
|
||||
@Param("route") String route, @Param("dispatchReview") String dispatchReview, @Param("loadingReview") String loadingReview,
|
||||
@Param("at") java.time.LocalDateTime at);
|
||||
|
||||
/** 父锁内按公共版本保存状态及审核依据,显式清空旧依据而不依赖实体非空更新策略。 */
|
||||
@Update("""
|
||||
|
||||
+10
-11
@@ -20,7 +20,7 @@ public class DispatchPdaController {
|
||||
@Resource private DispatchPdaQueryService queries;
|
||||
@Resource private DispatchPickingService picking;
|
||||
@Resource private DispatchAggregateService workflow;
|
||||
@Resource private DispatchDepartureService departure;
|
||||
@Resource private DispatchLoadingDepartureService loadingDeparture;
|
||||
|
||||
/** 批量读取当前绑定仓的整车作业。 */
|
||||
@GetMapping
|
||||
@@ -36,6 +36,13 @@ public class DispatchPdaController {
|
||||
return ApiResponse.success(queries.task(DispatchActorFactory.pda(user),dispatchNo));
|
||||
}
|
||||
|
||||
/** 理货审核与后台共用同一轮次、版本和幂等账本,驳回须填写原因。 */
|
||||
@PostMapping("/{dispatchNo}/loading-review")
|
||||
public ApiResponse<DispatchContracts.CommandResult> loadingReview(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String dispatchNo,@Valid @RequestBody DispatchCommands.ReviewCommand request) {
|
||||
return ApiResponse.success(workflow.review(DispatchActorFactory.pda(user),dispatchNo,true,request));
|
||||
}
|
||||
|
||||
/** 共用公共版本和全局请求键执行一次真实库存扫码。 */
|
||||
@PostMapping("/{dispatchNo}/items/{itemId}/pick")
|
||||
public ApiResponse<DispatchContracts.CommandResult> pick(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@@ -50,18 +57,10 @@ public class DispatchPdaController {
|
||||
return ApiResponse.success(workflow.loading(DispatchActorFactory.pda(user),dispatchNo));
|
||||
}
|
||||
|
||||
/** 最后一次扫码不代替显式装车确认,双方使用同一正式命令。 */
|
||||
/** 真实扫码核平后确认装车即发车,两端使用同一事务命令。 */
|
||||
@PostMapping("/{dispatchNo}/loading-complete")
|
||||
public ApiResponse<DispatchContracts.CommandResult> loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) {
|
||||
return ApiResponse.success(workflow.loadingComplete(DispatchActorFactory.pda(user),dispatchNo,request));
|
||||
}
|
||||
|
||||
/** 与平台共用实际发车服务,不执行第二次库存扣减。 */
|
||||
@PostMapping("/{dispatchNo}/depart")
|
||||
public ApiResponse<DispatchContracts.CommandResult> depart(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String dispatchNo,
|
||||
@Valid @RequestBody DispatchCommands.Depart request) {
|
||||
return ApiResponse.success(departure.depart(DispatchActorFactory.pda(user),dispatchNo,request));
|
||||
return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.pda(user),dispatchNo,request));
|
||||
}
|
||||
|
||||
/** 原动作权限与绑定仓同时核对未知结果,不按单客户任务恢复。 */
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ public interface DispatchWholePdaReadMapper {
|
||||
/** 精确单号或有界阶段列表均先在SQL限制绑定仓。 */
|
||||
@Select("<script>SELECT * FROM ll_dispatch_order WHERE overseas_warehouse_id=#{warehouse} AND status='ACTIVE' "
|
||||
+"<choose><when test='number != null'>AND dispatch_no=#{number} </when><when test='stage != null'>AND stage=#{stage} </when>"
|
||||
+"<otherwise>AND stage IN('LOADING','AFTER_LOADING','DELIVERING') </otherwise></choose>ORDER BY updated_at DESC,id DESC LIMIT 201</script>")
|
||||
+"<otherwise>AND (stage IN('LOADING','AFTER_LOADING','DELIVERING') OR (stage='BEFORE_LOADING' AND review_state='LOADING_PENDING')) </otherwise></choose>ORDER BY updated_at DESC,id DESC LIMIT 201</script>")
|
||||
List<DispatchAggregate> headers(@Param("warehouse")String warehouse,@Param("number")String number,@Param("stage")String stage);
|
||||
String ROOTS="<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>";
|
||||
/** 一次读取选中整车所有真实客户货物。 */
|
||||
|
||||
@@ -24,9 +24,6 @@ public final class DispatchApi {
|
||||
public static final String LOADING_REVIEW = "/{dispatchNo}/loading-review";
|
||||
public static final String RETURN_TO_REVIEW = "/{dispatchNo}/return-to-review";
|
||||
public static final String LOADING_COMPLETE = "/{dispatchNo}/loading-complete";
|
||||
public static final String DEPART = "/{dispatchNo}/depart";
|
||||
public static final String DELIVERY_ARRANGEMENT = "/{dispatchNo}/delivery-arrangement";
|
||||
public static final String TRANSFER_DELIVERY = "/{dispatchNo}/transfer-delivery";
|
||||
|
||||
/** 端点容器不允许实例化。 */
|
||||
private DispatchApi() { }
|
||||
|
||||
+3
@@ -7,6 +7,9 @@ import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
|
||||
|
||||
/** 派送域实现的整车安排和单据事实边界,禁止回写排车持久化实体。 */
|
||||
public interface DispatchDeliveryPort {
|
||||
/** 装车完成父事务自动准备全车真实客户单据,不要求人工安排或补造费用。 */
|
||||
ArrangementFact prepareForDeparture(Context context, Actor actor, String commandKey);
|
||||
|
||||
/** 一次预览返回同快照下全部客户目的地及费用证据。 */
|
||||
ArrangementPreview preview(Context context);
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ public final class DispatchViews {
|
||||
List<OrderDetail> orderDetails) { }
|
||||
|
||||
public record PdaTask(String taskNo, String dispatchNo, String warehouseId, Status status,
|
||||
Stage stage, long recordVersion, Reviews reviews, Counts counts,
|
||||
Stage stage, long recordVersion, Reviews reviews, Resources resources, Counts counts,
|
||||
boolean pickingReconciled, List<PdaItem> items, Map<Action, Gate> actions) { }
|
||||
|
||||
public record PdaItem(Long dispatchItemId, String customerId, String customerName, String palletNo,
|
||||
|
||||
+2
-2
@@ -46,8 +46,8 @@ public class DispatchAggregateRepository {
|
||||
public int removeItem(long itemId) {return items.deleteById(itemId);}
|
||||
/** 原子更新货量并作废旧审核依据。 */
|
||||
public int changeCargo(long id, long version, String hash, int itemCount, int pallets, int packages,
|
||||
String route, String pending, LocalDateTime at) {
|
||||
return mapper.changeCargo(id, version, hash, itemCount, pallets, packages, route, pending, at);
|
||||
String route, String dispatchReview, String loadingReview, LocalDateTime at) {
|
||||
return mapper.changeCargo(id, version, hash, itemCount, pallets, packages, route, dispatchReview, loadingReview, at);
|
||||
}
|
||||
/** 追加不可变业务快照。 */
|
||||
public int history(long id, DispatchAggregate row, String action, String operator, String reason, String snapshot, LocalDateTime at) {
|
||||
|
||||
+50
-10
@@ -400,7 +400,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
/** 调度与理货审核按同一轮次、同一事实摘要串行执行;理货通过才进入装车。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public CommandResult review(Actor actor,String number,boolean loading,DispatchCommands.ReviewCommand request) {
|
||||
var row=lockPlatform(actor,number);Action action=loading?Action.LOADING_REVIEW:Action.DISPATCH_REVIEW;
|
||||
Action action=loading?Action.LOADING_REVIEW:Action.DISPATCH_REVIEW;
|
||||
// PDA仅可处理本绑定仓理货审核,原键重试也先重新核验权限和范围。
|
||||
DispatchAggregate row;
|
||||
if(actor!=null && actor.mode()==ActorMode.PDA) {
|
||||
if(!loading || actor.boundWarehouseId()==null || !hasPermission(actor,action))
|
||||
throw new BizException(ResultCode.FORBIDDEN,"仅当前绑定仓理货员可执行理货审核");
|
||||
required(actor,number,false);fleet.lockDispatchShell(number);row=required(actor,number,true);
|
||||
} else row=lockPlatform(actor,number);
|
||||
var identity=identity(actor,number,action,request.recordVersion(),request.idempotencyKey(),request);
|
||||
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
|
||||
requireEditing(row,request.recordVersion());
|
||||
@@ -422,6 +429,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name());
|
||||
row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null):pending(row.getReviewRound())));
|
||||
}
|
||||
if(request.decision()==Decision.REJECTED) {
|
||||
// 任一级驳回均回客服处理,旧调度批准不再作为可继续理货的有效依据。
|
||||
row.setStage(Stage.BEFORE_LOADING.name());row.setReviewBasisHash(null);
|
||||
if(loading)row.setDispatchReviewJson(write(new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,
|
||||
row.getReviewRound(),null,"理货审核驳回,请客服修改后重新提交调度审核")));
|
||||
}
|
||||
return saveCommand(row,actor,identity,request.reason());
|
||||
}
|
||||
|
||||
@@ -517,7 +530,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
List.copyOf(pallets),row.getLoadingConfirmationJson()==null?null:read(row.getLoadingConfirmationJson(),ConfirmedLoad.class),gate,List.copyOf(missing));
|
||||
}
|
||||
|
||||
/** 最后一次扫码只核平,显式装车完成才保存实载并进入装车后。 */
|
||||
/** 内部实载步骤:扫码核平后保存装载证据,中间装车后状态由外层同事务继续推进发车。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public CommandResult loadingComplete(Actor actor,String number,DispatchCommands.LoadingComplete request) {
|
||||
required(actor,number,false);fleet.lockDispatchShell(number);var row=required(actor,number,true);
|
||||
@@ -610,7 +623,15 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
private void invalidate(DispatchAggregate row,String reason) {
|
||||
row.setReviewState(ReviewState.DRAFT.name());row.setReviewBasisHash(null);
|
||||
var invalid=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,row.getReviewRound(),null,reason);
|
||||
row.setDispatchReviewJson(write(invalid));row.setLoadingReviewJson(write(invalid));
|
||||
var previous=reviews(row);
|
||||
row.setDispatchReviewJson(write(retainRejection(previous.dispatchReview(),invalid)));
|
||||
row.setLoadingReviewJson(write(retainRejection(previous.loadingReview(),invalid)));
|
||||
}
|
||||
/** 客服改资料期间保留本轮驳回原因;下一轮提交统一重置,不复用批准。 */
|
||||
private static Review retainRejection(Review previous,Review invalid) {
|
||||
return previous.status()==ReviewStatus.REJECTED
|
||||
?new Review(previous.status(),previous.reason(),previous.reviewerId(),previous.reviewedAt(),previous.reviewRound(),null,invalid.invalidatedReason())
|
||||
:invalid;
|
||||
}
|
||||
/** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */
|
||||
void verifyReviewResources(DispatchAggregate row) {
|
||||
@@ -726,7 +747,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
int pallets=(int)all.stream().map(i -> i.getWarehousePalletId()==null ? "PLAN:"+i.getPlanPalletId() : "PALLET:"+i.getWarehousePalletId()).distinct().count();
|
||||
String route=all.stream().map(this::destinationLabel).filter(Objects::nonNull).distinct().sorted().collect(Collectors.joining(","));
|
||||
var digest=all.stream().sorted(Comparator.comparing(DispatchItem::getSourceKey)).map(i -> List.of(i.getSourceKey(),i.getSourceVersion(),i.getCustomerId(),i.getPackageCount())).toList();
|
||||
if(repository.changeCargo(row.getId(),row.getRecordVersion(),hashes.source(digest),all.size(),pallets,packages,route,write(pending(row.getReviewRound())),now())!=1) throw conflict("排车版本已变化,未保存本次货物");
|
||||
invalidate(row,"排车货物已修改,请客服重新提交审核");
|
||||
if(repository.changeCargo(row.getId(),row.getRecordVersion(),hashes.source(digest),all.size(),pallets,packages,route,row.getDispatchReviewJson(),row.getLoadingReviewJson(),now())!=1) throw conflict("排车版本已变化,未保存本次货物");
|
||||
var stops=all.stream().map(i -> new FleetExecutionModels.DispatchStop(destinationLabel(i),i.getDeliveryAddress(),destination(i).key()))
|
||||
.collect(Collectors.toMap(FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
|
||||
fleet.updateDispatchCargo(row.getDispatchNo(),packages,stops);
|
||||
@@ -775,12 +797,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
// 到仓取货和本土卡车沿运输任务执行,不能进入仓内拣货、扣库存和转派送链。
|
||||
if (row.getTaskType()!=null && !"DELIVERY".equals(row.getTaskType())) {
|
||||
var blocked=new EnumMap<Action,Gate>(Action.class);
|
||||
for(var action:Action.values())blocked.put(action,new Gate(false,"TRANSPORT_TASK","请在排车运输任务中操作"));
|
||||
for(var action:Action.values())if(action!=Action.ARRANGE_DELIVERY && action!=Action.DEPART)
|
||||
blocked.put(action,new Gate(false,"TRANSPORT_TASK","请在排车运输任务中操作"));
|
||||
return Collections.unmodifiableMap(blocked);
|
||||
}
|
||||
var gates=new EnumMap<Action,Gate>(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus());
|
||||
Stage stage=Stage.valueOf(row.getStage()); ReviewState review=ReviewState.valueOf(row.getReviewState());
|
||||
for(var action:Action.values()) {
|
||||
if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)continue;
|
||||
boolean state=switch(action) {
|
||||
case CREATE -> actor.mode()==ActorMode.PLATFORM;
|
||||
case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0;
|
||||
@@ -799,11 +823,20 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
return Collections.unmodifiableMap(gates);
|
||||
}
|
||||
|
||||
/** 装车发运及原结果恢复都按当前装车权限与PDA绑定身份校验。 */
|
||||
void requireLoadingPermission(Actor actor) {
|
||||
if(actor==null || actor.operatorId()==null || !hasPermission(actor,Action.LOADING_COMPLETE))
|
||||
throw new BizException(ResultCode.FORBIDDEN,"当前无装车完成权限");
|
||||
}
|
||||
|
||||
/** 主体类型和正式动作权限逐项判断,不把list权限等同写权限。 */
|
||||
private static boolean hasPermission(Actor actor,Action action) {
|
||||
if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)return false;
|
||||
var auth=SecurityContextHolder.getContext().getAuthentication(); if(auth==null || actor==null) return false;
|
||||
if(actor.mode()==ActorMode.PDA) return Set.of(Action.PICK,Action.LOADING_COMPLETE,Action.DEPART).contains(action)
|
||||
&& auth.getAuthorities().stream().anyMatch(a->"lastleg:pda:operate".equals(a.getAuthority()));
|
||||
if(actor.mode()==ActorMode.PDA) return Set.of(Action.LOADING_REVIEW,Action.PICK,Action.LOADING_COMPLETE,Action.DEPART).contains(action)
|
||||
&& actor.boundWarehouseId()!=null
|
||||
&& auth.getAuthorities().stream().anyMatch(a->"lastleg:pda:operate".equals(a.getAuthority()))
|
||||
&& auth.getAuthorities().stream().anyMatch(a->"ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR".equals(a.getAuthority()));
|
||||
if(action==Action.PICK) return false;
|
||||
String permission=switch(action) {
|
||||
case CREATE -> PREFIX+"create"; case CANCEL -> PREFIX+"cancel";
|
||||
@@ -814,6 +847,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
return auth.getAuthorities().stream().anyMatch(a->permission.equals(a.getAuthority()));
|
||||
}
|
||||
|
||||
/** PDA审核读取整车冻结资源,与后台共用相同快照,查询不更改主档。 */
|
||||
Resources resources(DispatchAggregate row) { return read(row.getResourcesJson(),Resources.class); }
|
||||
|
||||
/** 统计按真整车去重并补齐全部零项。 */
|
||||
private static DispatchViews.Statistics statistics(List<DispatchAggregateMapper.Summary> groups) {
|
||||
var stages=new EnumMap<Stage,Long>(Stage.class); for(var stage:Stage.values()) stages.put(stage,0L);
|
||||
@@ -821,9 +857,13 @@ 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);
|
||||
if(stage==Stage.BEFORE_LOADING) { before+=group.quantity(); var review=ReviewState.valueOf(group.reviewState());
|
||||
dispatch[review==ReviewState.DRAFT?0:review==ReviewState.DISPATCH_PENDING?1:review==ReviewState.DISPATCH_REJECTED?3:2]+=group.quantity();
|
||||
loading[review==ReviewState.LOADING_PENDING?1:review==ReviewState.LOADING_REJECTED?3:review==ReviewState.APPROVED?2:0]+=group.quantity(); }
|
||||
if(stage==Stage.BEFORE_LOADING) {
|
||||
// 三分区表示当前待办,驳回只能计入客服处理,不能留在审核队列。
|
||||
var review=ReviewState.valueOf(group.reviewState());
|
||||
if(canSubmitReview(review.name()))before+=group.quantity();
|
||||
else if(review==ReviewState.DISPATCH_PENDING)dispatch[1]+=group.quantity();
|
||||
else if(review==ReviewState.LOADING_PENDING)loading[1]+=group.quantity();
|
||||
}
|
||||
}
|
||||
return new DispatchViews.Statistics(active+cancelled,active,cancelled,Collections.unmodifiableMap(stages),new DispatchViews.BeforeLoadingCounts(before,
|
||||
new DispatchViews.ReviewCounts(dispatch[0],dispatch[1],dispatch[2],dispatch[3]),new DispatchViews.ReviewCounts(loading[0],loading[1],loading[2],loading[3])));
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import java.util.List;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
|
||||
/** 两端装车完成即发车:所有中间状态只存在于同一事务,原请求只恢复最终结果。 */
|
||||
@Service
|
||||
public class DispatchLoadingDepartureService {
|
||||
private final DispatchAggregateService workflow;
|
||||
private final DispatchDeliveryPort delivery;
|
||||
private final DispatchDepartureService departure;
|
||||
private final DispatchCommandLedger commands;
|
||||
private final DispatchCommandHasher hashes;
|
||||
|
||||
/** 复用装车、派送和发车的真实规则,不另建库存或运输状态实现。 */
|
||||
public DispatchLoadingDepartureService(DispatchAggregateService workflow,DispatchDeliveryPort delivery,
|
||||
DispatchDepartureService departure,DispatchCommandLedger commands,DispatchCommandHasher hashes) {
|
||||
this.workflow=workflow;this.delivery=delivery;this.departure=departure;this.commands=commands;this.hashes=hashes;
|
||||
}
|
||||
|
||||
/** 原键恢复先于阶段校验;任一步失败均回滚装载、子单、费用、发车与全部账本。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public CommandResult complete(Actor actor,String number,DispatchCommands.LoadingComplete request) {
|
||||
workflow.requireLoadingPermission(actor);
|
||||
var context=workflow.lockForArrangement(actor,number);
|
||||
var identity=new CommandIdentity(Action.LOADING_COMPLETE,request.idempotencyKey(),request.recordVersion(),
|
||||
hashes.command(actor,Action.LOADING_COMPLETE,number,request.recordVersion(),request));
|
||||
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
|
||||
// 先取得本车单据锁再申请真实来源锁,后续所有步骤保持同一个整车父锁。
|
||||
delivery.departureFacts(context,true);
|
||||
var loaded=workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(),
|
||||
stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.measurements(),request.remark()));
|
||||
context=workflow.preview(actor,number);
|
||||
String arrangementKey=stepKey(number,request.idempotencyKey(),"ARRANGE");
|
||||
var fact=delivery.prepareForDeparture(context,actor,arrangementKey);
|
||||
var arrangementIdentity=new CommandIdentity(Action.ARRANGE_DELIVERY,arrangementKey,context.recordVersion(),
|
||||
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,request.idempotencyKey(),"DEPART"),request.remark()));
|
||||
if(departed.stage()!=Stage.DELIVERING || !(loaded.result() instanceof DispatchResults.Loaded))
|
||||
throw new BizException(ResultCode.CONFLICT,"装车发运结果未核平");
|
||||
var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),departed.recordVersion(),
|
||||
departed.status(),departed.stage(),departed.occurredAt(),Effect.APPLIED,departed.counts(),loaded.result(),false,false);
|
||||
commands.append(context.dispatchId(),actor,identity,result);return result;
|
||||
}
|
||||
|
||||
/** 固定长度内部键绑定原请求和步骤,不向客户端暴露中间命令作为恢复入口。 */
|
||||
private String stepKey(String number,String original,String step) {
|
||||
return "AUTO_"+step+"_"+hashes.source(List.of(number,original,step));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -89,7 +89,7 @@ public class DispatchPdaQueryService {
|
||||
else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车"));
|
||||
var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),n(root.getPackageCount()),n(root.getPickedPackageCount()),n(root.getShippedPackageCount()));
|
||||
// 整车作业直接以排车业务号识别;真实客户子taskId只在服务端审计保存。
|
||||
result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions)));
|
||||
result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),workflow.resources(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions)));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
@@ -1345,49 +1345,6 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:
|
||||
post:
|
||||
summary: 原子安排本车全部客户派送及费用
|
||||
description: 权限 platform:overseas-warehouse:dispatch:transfer-delivery。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
requestId:
|
||||
type: string
|
||||
nullable: true
|
||||
traceId:
|
||||
type: string
|
||||
nullable: true
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
data:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DispatchCommandResult'
|
||||
nullable: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DispatchArrangeDelivery'
|
||||
parameters:
|
||||
- name: dispatchNo
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/items:
|
||||
post:
|
||||
summary: 按真实来源版本向整车加货
|
||||
@@ -1727,8 +1684,8 @@ paths:
|
||||
type: string
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
|
||||
post:
|
||||
summary: PDA核平后确认装车完成
|
||||
description: 权限 platform:overseas-warehouse:dispatch:loading-complete。
|
||||
summary: 确认装车完成并自动进入派送中
|
||||
description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
@@ -1878,43 +1835,6 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/delivery-arrangement:
|
||||
get:
|
||||
summary: 读取全车派送分组与费用依据
|
||||
description: 权限 platform:overseas-warehouse:dispatch:list。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
requestId:
|
||||
type: string
|
||||
nullable: true
|
||||
traceId:
|
||||
type: string
|
||||
nullable: true
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
data:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DispatchArrangementPreview'
|
||||
nullable: true
|
||||
parameters:
|
||||
- name: dispatchNo
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/commands/{idempotencyKey}:
|
||||
get:
|
||||
summary: 恢复本人原动作不可变结果
|
||||
@@ -2107,49 +2027,6 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
pattern: ^[1-9][0-9]*$
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/depart:
|
||||
post:
|
||||
summary: 确认实际整车发车
|
||||
description: 权限 platform:overseas-warehouse:dispatch:depart。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
requestId:
|
||||
type: string
|
||||
nullable: true
|
||||
traceId:
|
||||
type: string
|
||||
nullable: true
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
data:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DispatchCommandResult'
|
||||
nullable: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DispatchDepart'
|
||||
parameters:
|
||||
- name: dispatchNo
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
/api/platform/v1/last-leg/outbound-orders:
|
||||
get:
|
||||
summary: 分页查询出库单
|
||||
@@ -6586,6 +6463,10 @@ components:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DispatchReviews'
|
||||
nullable: true
|
||||
resources:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DispatchResources'
|
||||
nullable: true
|
||||
counts:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DispatchCounts'
|
||||
@@ -6610,6 +6491,7 @@ components:
|
||||
- stage
|
||||
- recordVersion
|
||||
- reviews
|
||||
- resources
|
||||
- counts
|
||||
- pickingReconciled
|
||||
- items
|
||||
|
||||
@@ -9,7 +9,7 @@ paths:
|
||||
/api/pda/v1/last-leg/dispatches:
|
||||
get:
|
||||
summary: 读取Token绑定仓五阶段整车任务
|
||||
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。不接收客户或仓库范围参数;真实客户归属保留在items,整车公共版本及Gate由服务端返回。
|
||||
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。不接收客户或仓库范围参数;真实客户归属保留在items,整车公共版本及Gate由服务端返回。未指定阶段时返回待理货审核及装车、装车后、派送中的任务;车辆与司机依据读取resources。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
@@ -208,10 +208,53 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review:
|
||||
post:
|
||||
summary: 理货员审核整车,后台与PDA共用审核轮次
|
||||
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。仅绑定仓且调度审核通过的本轮整车可审核;REJECTED 必填 reason 并退回客服处理,APPROVED 才进入 LOADING;公共版本拒绝双端重复审核,原键恢复仍核验本人、绑定仓和理货权限。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
requestId:
|
||||
type: string
|
||||
nullable: true
|
||||
traceId:
|
||||
type: string
|
||||
nullable: true
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
data:
|
||||
allOf:
|
||||
- $ref: overseas-business-v1.yaml#/components/schemas/DispatchCommandResult
|
||||
nullable: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: overseas-business-v1.yaml#/components/schemas/DispatchReviewCommand
|
||||
parameters:
|
||||
- name: dispatchNo
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
|
||||
post:
|
||||
summary: 真实拣货核平后显式确认装车完成
|
||||
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。
|
||||
summary: 确认装车完成并自动进入派送中
|
||||
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
@@ -251,49 +294,6 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:
|
||||
post:
|
||||
summary: 完整安排派送后确认实际发车
|
||||
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
requestId:
|
||||
type: string
|
||||
nullable: true
|
||||
traceId:
|
||||
type: string
|
||||
nullable: true
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
data:
|
||||
allOf:
|
||||
- $ref: overseas-business-v1.yaml#/components/schemas/DispatchCommandResult
|
||||
nullable: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: overseas-business-v1.yaml#/components/schemas/DispatchDepart
|
||||
parameters:
|
||||
- name: dispatchNo
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
/api/pda/v1/last-leg/outbound-tasks:
|
||||
get:
|
||||
summary: 查询Token绑定仓的一件代发出库任务
|
||||
|
||||
+28
@@ -37,6 +37,7 @@ import static org.mockito.Mockito.*;
|
||||
/** 真实生产Mapper及事务验证任何客户或主单CAS失败时全车原子回滚。 */
|
||||
class DispatchDeliveryArrangementPersistenceTest {
|
||||
private JdbcTemplate db;
|
||||
private org.springframework.transaction.support.TransactionTemplate tx;
|
||||
private DispatchDeliveryService service;
|
||||
private final DispatchExecutionPort execution=mock(DispatchExecutionPort.class);
|
||||
private final DispatchDeliveryFees fees=mock(DispatchDeliveryFees.class);
|
||||
@@ -49,6 +50,7 @@ class DispatchDeliveryArrangementPersistenceTest {
|
||||
@BeforeEach void prepare() throws Exception {
|
||||
var ds=new DriverManagerDataSource("jdbc:h2:mem:arrangement-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
db=new JdbcTemplate(ds);
|
||||
tx=new org.springframework.transaction.support.TransactionTemplate(new DataSourceTransactionManager(ds));
|
||||
for (var type:List.of(OutboundOrder.class,OutboundPalletItem.class,DeliveryOrder.class,LastlegOrder.class,LastlegOrderStatusHistory.class)) {
|
||||
String columns=Arrays.stream(type.getDeclaredFields()).filter(f->!Modifier.isStatic(f.getModifiers())).map(f->{
|
||||
String name=f.getName().replaceAll("([A-Z])","_$1").toLowerCase();
|
||||
@@ -173,6 +175,32 @@ class DispatchDeliveryArrangementPersistenceTest {
|
||||
assertEquals(6L,frozen.stream().filter(c->c.attributionId()==4).findFirst().orElseThrow().sourceOrigin().productQuantity());
|
||||
}
|
||||
|
||||
/** 自动装车加入父事务,最终主单失败时全部客户单据与来源绑定均回滚。 */
|
||||
@Test void automaticPreparationSharesLoadingParentTransactionAndRollback() {
|
||||
assertThrows(IllegalStateException.class,()->tx.execute(status->{
|
||||
service.prepareForDeparture(context,actor,"LOADING-FAIL");
|
||||
throw new IllegalStateException("装车最终保存失败");
|
||||
}));
|
||||
for(var table:List.of("ll_outbound_order","ll_outbound_pallet_item","ll_delivery_order","ll_dispatch_delivery_arrangement","ll_loading_order_status_history"))
|
||||
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM "+table,Integer.class));
|
||||
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_order WHERE outbound_order_no IS NULL AND version=0",Integer.class));
|
||||
var fact=tx.execute(status->service.prepareForDeparture(context,actor,"LOADING-OK"));
|
||||
assertEquals(2,fact.result().deliveries().size());
|
||||
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order WHERE planned_delivery_at IS NULL AND delivery_method IS NULL AND pickup_method IS NULL AND loading_type IS NULL",Integer.class));
|
||||
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order WHERE fee_coverage_json IS NULL",Integer.class));
|
||||
clearInvocations(fees);
|
||||
assertEquals(fact,tx.execute(status->service.prepareForDeparture(context,actor,"LOADING-OK")));
|
||||
verifyNoInteractions(fees);
|
||||
}
|
||||
|
||||
/** 外接费用发布失败同样回滚自动生成的全部客户单据,不能留下半车。 */
|
||||
@Test void automaticExternalFreightFailureRollsBackEveryCustomer() {
|
||||
doThrow(new IllegalStateException("外接费用发布失败")).when(fees).publish(any(),argThat(g->g.customerId().equals("102")),any(),any(),any(),any(),anyString(),anyString());
|
||||
assertThrows(IllegalStateException.class,()->tx.execute(status->service.prepareForDeparture(context,actor,"LOADING-FAIL")));
|
||||
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Integer.class));
|
||||
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Integer.class));
|
||||
}
|
||||
|
||||
/** 按真实共享记录构造完整客户目的地请求。 */
|
||||
private ArrangeDelivery request() { return new ArrangeDelivery(3L,"ARRANGE-1","cargo-hash","loading-hash",grouping.groups(context).stream().map(g->
|
||||
new ArrangeGroup(g.key(),g.cargoHash(),DEPARTED,"TRUCK",null,null,"WAREHOUSE_PICKUP","PALLET",null,null,null,null,null,
|
||||
|
||||
+32
@@ -72,6 +72,38 @@ class DispatchDeliveryServiceTest {
|
||||
verify(fees,times(3)).publish(any(),any(),any(),any(),isNull(),any(),eq("OP-A"),anyString());
|
||||
}
|
||||
|
||||
/** PDA装车自动生成完整客户目的地,不猜派送计划、不要求人工费用或原费覆盖。 */
|
||||
@Test void loadingAutomaticallyCreatesDocumentsWithoutInventedInputsOrFees() {
|
||||
var pda=new Actor("PDA-1",ActorMode.PDA,"1001");
|
||||
var fact=service.prepareForDeparture(context,pda,"LOADING-1");
|
||||
assertEquals(2,fact.result().outbounds().size());assertEquals(3,fact.result().deliveries().size());
|
||||
assertEquals(4,fact.result().recordVersion());
|
||||
assertTrue(deliveries.stream().allMatch(d->d.getPlannedDeliveryAt()==null && d.getDeliveryMethod()==null
|
||||
&& d.getPickupMethod()==null && d.getLoadingType()==null));
|
||||
assertTrue(outbounds.stream().allMatch(o->o.getFeeCoverageJson()==null));
|
||||
assertTrue(calls.indexOf("SOURCE")>calls.lastIndexOf("DELIVERY"));
|
||||
verify(fees,never()).validate(any(),any(),any(),any());verify(fees,never()).coverage(any(),any(),any(),any());
|
||||
var inputs=org.mockito.ArgumentCaptor.forClass(ArrangeGroup.class);
|
||||
verify(fees,times(3)).publish(eq(context),any(),inputs.capture(),any(),isNull(),any(),eq("PDA-1"),anyString());
|
||||
assertTrue(inputs.getAllValues().stream().allMatch(i->i.fees().isEmpty() && i.feeCoverage().isEmpty()));
|
||||
clearInvocations(fees,repository);
|
||||
assertEquals(fact,service.prepareForDeparture(context,pda,"LOADING-1"));
|
||||
verifyNoInteractions(fees);verify(repository,never()).create(any(OutboundOrder.class));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,
|
||||
()->service.prepareForDeparture(context,pda,"DIFFERENT-KEY"));
|
||||
}
|
||||
|
||||
/** 自动生成仍拒绝越仓与孤立子单,不为异常半车补造第二套事实。 */
|
||||
@Test void automaticPreparationRejectsWrongWarehouseAndOrphanDocuments() {
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,
|
||||
()->service.prepareForDeparture(context,new Actor("PDA-1",ActorMode.PDA,"OTHER"),"LOADING-1"));
|
||||
verifyNoInteractions(repository,fees);
|
||||
outbounds.add(new OutboundOrder());
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,
|
||||
()->service.prepareForDeparture(context,actor,"LOADING-1"));
|
||||
verifyNoInteractions(fees);
|
||||
}
|
||||
|
||||
/** 同实托的两申请分别冻结原货源,出库引用逐次归属且真实客户目的地与履约订单不被替换。 */
|
||||
@Test void relabelOriginsStayFrozenBehindOutboundAttributionReferences() throws Exception {
|
||||
var first=new SourceOrigin(10001L,201L,301L,401L,701L,"IN-701","CODE-701",null);
|
||||
|
||||
+7
-22
@@ -97,8 +97,6 @@ class PlatformDispatchOutboundContractTest {
|
||||
String.class, DispatchCommands.Update.class);
|
||||
Method cancel = DispatchAggregateController.class.getMethod("cancel", AuthenticatedUser.class,
|
||||
String.class, DispatchCommands.ReasonCommand.class);
|
||||
Method transfer = DeliveryTransferController.class.getMethod("transfer", AuthenticatedUser.class,
|
||||
String.class, com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.ArrangeDelivery.class);
|
||||
Method externalFreight = DeliveryTransferController.class.getMethod("externalFreight", AuthenticatedUser.class, String.class);
|
||||
assertPermission(externalFreight, OverseasPermissions.DISPATCH_LIST);
|
||||
Method outboundDetail = DeliveryTransferController.class.getMethod("outboundDetail",
|
||||
@@ -113,11 +111,12 @@ class PlatformDispatchOutboundContractTest {
|
||||
assertEquals(Set.of("/{dispatchNo}/cancel"), Set.of(cancelRoute.value()));
|
||||
assertFalse(cancel.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class));
|
||||
assertTrue(cancel.getParameters()[2].isAnnotationPresent(org.springframework.web.bind.annotation.RequestBody.class));
|
||||
assertPermission(transfer, OverseasPermissions.DISPATCH_TRANSFER_DELIVERY);
|
||||
assertTrue(Arrays.stream(DeliveryTransferController.class.getDeclaredMethods())
|
||||
.noneMatch(method -> Set.of("transfer", "arrangement").contains(method.getName())));
|
||||
assertPermission(outboundDetail, OverseasPermissions.OUTBOUND_LIST);
|
||||
assertEquals(2, outboundDetail.getParameterCount());
|
||||
assertPermission(outboundCancel, OverseasPermissions.OUTBOUND_CANCEL);
|
||||
for (Method method : Set.of(update, cancel, transfer, externalFreight, outboundDetail, outboundCancel)) {
|
||||
for (Method method : Set.of(update, cancel, externalFreight, outboundDetail, outboundCancel)) {
|
||||
assertTrue(Arrays.stream(method.getParameters())
|
||||
.noneMatch(parameter -> parameter.isAnnotationPresent(RequestParam.class)));
|
||||
}
|
||||
@@ -202,16 +201,13 @@ class PlatformDispatchOutboundContractTest {
|
||||
assertFalse(schema.contains("uk_ll_outbound_no (customer_id,outbound_no)"));
|
||||
}
|
||||
|
||||
/** 当前整车请求按公共版本一次提交全部组,响应保留多客户出库及多目的地派送数组。 */
|
||||
/** 公开协议撤销人工安排入口,内部完整派送事实仍保留多客户和多目的地数组。 */
|
||||
@Test
|
||||
void openApiPublishesOnlyTheFrozenPlatformProtocol() throws Exception {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"),
|
||||
StandardCharsets.UTF_8);
|
||||
String dispatchAction = slice(yaml, " /api/platform/v1/last-leg/dispatches/{dispatchNo}:",
|
||||
" /api/platform/v1/last-leg/dispatches/candidates:");
|
||||
String transfer = slice(yaml,
|
||||
" /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:",
|
||||
" /api/platform/v1/last-leg/outbound-orders:");
|
||||
String outboundDetail = slice(yaml,
|
||||
" /api/platform/v1/last-leg/outbound-orders/{outboundNo}:",
|
||||
" /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:");
|
||||
@@ -219,7 +215,7 @@ class PlatformDispatchOutboundContractTest {
|
||||
" /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:",
|
||||
" /api/platform/v1/last-leg/overseas-procurements:");
|
||||
|
||||
for (String action : Set.of(dispatchAction, transfer, outboundDetail, outboundCancel)) {
|
||||
for (String action : Set.of(dispatchAction, outboundDetail, outboundCancel)) {
|
||||
assertFalse(action.contains("name: customerId"));
|
||||
}
|
||||
assertTrue(outboundDetail.contains("pallets[].palletNo"));
|
||||
@@ -228,24 +224,13 @@ class PlatformDispatchOutboundContractTest {
|
||||
assertTrue(yaml.contains("单次 SQL 返回,不逐行补查"));
|
||||
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:edit"));
|
||||
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:cancel"));
|
||||
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:transfer-delivery"));
|
||||
assertFalse(yaml.contains(" /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:"));
|
||||
assertFalse(yaml.contains(" /api/platform/v1/last-leg/dispatches/{dispatchNo}/delivery-arrangement:"));
|
||||
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:list"));
|
||||
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:create"));
|
||||
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:cancel"));
|
||||
java.util.Map<?,?> document = new org.yaml.snakeyaml.Yaml().load(yaml);
|
||||
java.util.Map<?,?> schemas = (java.util.Map<?,?>)((java.util.Map<?,?>)document.get("components")).get("schemas");
|
||||
java.util.Map<?,?> arrange = (java.util.Map<?,?>)schemas.get("DispatchArrangeDelivery");
|
||||
assertNotNull(arrange);
|
||||
assertTrue(((java.util.List<?>)arrange.get("required")).containsAll(java.util.List.of("recordVersion", "idempotencyKey")));
|
||||
java.util.Map<?,?> fields = (java.util.Map<?,?>)arrange.get("properties");
|
||||
assertTrue(fields.keySet().containsAll(java.util.Set.of("recordVersion", "idempotencyKey", "cargoHash", "loadingCompletionHash", "groups")));
|
||||
assertFalse(fields.containsKey("customerId"));
|
||||
assertFalse(fields.containsKey("expectedVersion"));
|
||||
java.util.Map<?,?> groups = (java.util.Map<?,?>)fields.get("groups");
|
||||
assertEquals("array", groups.get("type"));
|
||||
assertEquals("#/components/schemas/DispatchArrangeGroup", ((java.util.Map<?,?>)groups.get("items")).get("$ref"));
|
||||
java.util.Map<?,?> groupFields = (java.util.Map<?,?>)((java.util.Map<?,?>)schemas.get("DispatchArrangeGroup")).get("properties");
|
||||
assertTrue(groupFields.keySet().containsAll(java.util.Set.of("groupKey", "groupCargoHash", "fees", "feeCoverage")));
|
||||
java.util.Map<?,?> resultFields = (java.util.Map<?,?>)((java.util.Map<?,?>)schemas.get("DispatchArrangementResult")).get("properties");
|
||||
assertEquals("array", ((java.util.Map<?,?>)resultFields.get("outbounds")).get("type"));
|
||||
assertEquals("array", ((java.util.Map<?,?>)resultFields.get("deliveries")).get("type"));
|
||||
|
||||
+3
-2
@@ -419,12 +419,13 @@ public class DispatchAggregatePersistenceTest {
|
||||
assertThat(f.db.queryForList("SELECT DISTINCT customer_id FROM ll_dispatch_item ORDER BY customer_id",String.class)).containsExactly("101","202");
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(DISTINCT dispatch_order_id) FROM ll_dispatch_item",Long.class)).isOne();
|
||||
assertThat(f.db.queryForObject("SELECT package_count FROM ll_fleet_trip_dispatch",Integer.class)).isEqualTo(8);
|
||||
var query=new DispatchViews.Query(1,20,null,null,null,"101",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,BeforeLoadingSection.DISPATCH_REVIEW,ReviewStatus.NOT_SUBMITTED);
|
||||
var query=new DispatchViews.Query(1,20,null,null,null,"101",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,BeforeLoadingSection.VEHICLE_INFO,null);
|
||||
var page=f.tx.execute(s->f.service.page(f.actor,query));
|
||||
assertThat(page.total()).isOne(); assertThat(page.records()).hasSize(1);
|
||||
assertThat(page.records().get(0).counts().packageCount()).isEqualTo(8);
|
||||
assertThat(page.statistics().activeTotal()).isOne(); assertThat(page.statistics().stageCounts().get(Stage.BEFORE_LOADING)).isOne();
|
||||
assertThat(page.statistics().beforeLoadingReviewCounts().dispatchReview().notSubmitted()).isOne();
|
||||
assertThat(page.statistics().beforeLoadingReviewCounts().vehicleInfoTotal()).isOne();
|
||||
assertThat(page.statistics().beforeLoadingReviewCounts().dispatchReview().notSubmitted()).isZero();
|
||||
|
||||
long dispatch=f.db.queryForObject("SELECT id FROM ll_dispatch_order",Long.class);
|
||||
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE customer_id='101'",Long.class);
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.mapper.*;
|
||||
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.springframework.security.authentication.TestingAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
/** 装完即发车的共同事务、原键最终结果及跨端竞争使用真实根单据和版本。 */
|
||||
class DispatchLoadingDeparturePersistenceTest {
|
||||
/** 每例恢复隔离身份,不影响其他服务权限测试。 */
|
||||
@AfterEach void clearSecurity(){SecurityContextHolder.clearContext();}
|
||||
|
||||
/** 一次确认完成全部客户发运;原键重放返回派送中,不再生成子单或执行来源。 */
|
||||
@Test void loadingCompletesAllCustomersAndReplaysOnlyFinalResult() throws Exception {
|
||||
authorize();var f=new Fixture();
|
||||
var result=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
|
||||
assertThat(result.action()).isEqualTo(Action.LOADING_COMPLETE);assertThat(result.stage()).isEqualTo(Stage.DELIVERING);
|
||||
assertThat(result.counts().shippedPackageCount()).isEqualTo(8);assertThat(result.result()).isInstanceOf(DispatchResults.Loaded.class);
|
||||
assertThat(f.f.db.queryForList("SELECT status FROM ll_outbound_order",String.class)).containsExactly("OUTBOUNDED","OUTBOUNDED");
|
||||
assertThat(f.f.db.queryForList("SELECT status FROM ll_delivery_order",String.class)).containsExactly("DELIVERING","DELIVERING");
|
||||
assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("IN_TRANSIT");
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2);
|
||||
assertThat(f.f.db.queryForObject("SELECT result_json FROM ll_dispatch_command WHERE idempotency_key='complete'",String.class)).contains("DELIVERING");
|
||||
var replay=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
|
||||
assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(result.recordVersion());assertThat(replay.result()).isEqualTo(result.result());
|
||||
assertThat(f.f.tx.execute(s->f.f.service.command(f.f.actor,f.number,"complete")).stage()).isEqualTo(Stage.DELIVERING);
|
||||
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
|
||||
assertThat(f.f.service.actions(f.f.actor,f.f.repository.find(f.number))).doesNotContainKeys(Action.ARRANGE_DELIVERY,Action.DEPART);
|
||||
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("stale-new")))).hasMessageContaining("版本或审核阶段");
|
||||
}
|
||||
|
||||
/** 任一后续步骤失败都恢复装车中,已提交的真实拣货保持,不留下中间装车或子单。 */
|
||||
@Test void preparationAndDepartureFailuresRollBackTheEntireLoadingCommand() throws Exception {
|
||||
authorize();
|
||||
for(boolean failPreparation:List.of(true,false)) {
|
||||
var f=new Fixture();
|
||||
if(failPreparation)doThrow(new IllegalStateException("模拟单据准备失败")).when(f.delivery).prepareForDeparture(any(),any(),anyString());
|
||||
else doThrow(new IllegalStateException("模拟来源发运失败")).when(f.sourceWorkflow).departWholeSources(any(),any(),any(),any());
|
||||
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("failed")))).isInstanceOf(IllegalStateException.class);
|
||||
var row=f.f.repository.find(f.number);assertThat(row.getStage()).isEqualTo("LOADING");assertThat(row.getRecordVersion()).isEqualTo(f.version);
|
||||
assertThat(row.getLoadingCompletionHash()).isNull();assertThat(row.getArrangementKey()).isNull();assertThat(row.getDepartedAt()).isNull();
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Long.class)).isZero();
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero();
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action IN ('LOADING_COMPLETE','ARRANGE_DELIVERY','DEPART')",Long.class)).isZero();
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2);
|
||||
assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
|
||||
}
|
||||
}
|
||||
|
||||
/** PDA保持本仓权限、原键权限复验;不借自动流程绕开真实装载清单。 */
|
||||
@Test void pdaUsesOnePermissionButRejectsOtherWarehouseAndInvalidEvidence() throws Exception {
|
||||
authorize();var f=new Fixture();var pda=new Actor("picker",ActorMode.PDA,"9");
|
||||
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(new Actor("picker",ActorMode.PDA,"99"),f.number,f.request("foreign")))).hasMessageContaining("当前绑定仓");
|
||||
var bad=new DispatchCommands.LoadingComplete(f.version,"bad-manifest","changed",f.measurements,null);
|
||||
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,bad))).hasMessageContaining("真实装载清单已变化");
|
||||
verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());
|
||||
var result=f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")));assertThat(result.stage()).isEqualTo(Stage.DELIVERING);
|
||||
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("picker","","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
|
||||
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")))).hasMessageContaining("装车完成权限");
|
||||
}
|
||||
|
||||
/** 同一版本后台/PDA同时确认只有一条完整链提交,不会产生半车或重复派送。 */
|
||||
@Test void concurrentAdminAndPdaCompleteOnlyOneDeparture() throws Exception {
|
||||
authorize();var f=new Fixture();var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var a=workers.submit(()->f.tryComplete(start,f.f.actor,"admin"));
|
||||
var b=workers.submit(()->f.tryComplete(start,new Actor("picker",ActorMode.PDA,"9"),"pda"));
|
||||
start.countDown();assertThat(List.of(a.get(25,TimeUnit.SECONDS),b.get(25,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);
|
||||
} finally {workers.shutdownNow();}
|
||||
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING");
|
||||
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
|
||||
}
|
||||
|
||||
/** 仅配置装车完成及PDA既有角色权限,不授予人工派送、发车权限。 */
|
||||
private static void authorize(){SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator","",
|
||||
"platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));}
|
||||
|
||||
/** 主流程与发车使用真实SQL,派送提供者受控写入真实子单来验证共同回滚。 */
|
||||
private static final class Fixture {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
final DispatchDeliveryPort delivery=mock(DispatchDeliveryPort.class);
|
||||
final DispatchPdaWorkflowService sourceWorkflow=mock(DispatchPdaWorkflowService.class);
|
||||
final DispatchLoadingDepartureService service;final String number;final long version;final String manifest;
|
||||
final List<LoadingMeasurement> measurements;
|
||||
/** 创建两客户货物并走真实两级审核、真实拣货归属,不伪造已装车状态。 */
|
||||
Fixture() throws Exception {
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("create",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"202",502,"LAX9",5)))));
|
||||
number=created.dispatchNo();version=f.approve(created).recordVersion();
|
||||
f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8");
|
||||
f.db.execute("ALTER TABLE ll_order ADD detained INT DEFAULT 0");
|
||||
var preview=f.tx.execute(s->f.service.loading(f.actor,number));manifest=preview.manifestHash();
|
||||
measurements=preview.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,BigDecimal.ONE,BigDecimal.ONE,p.deliveryIndex())).toList();
|
||||
when(delivery.prepareForDeparture(any(),any(),anyString())).thenAnswer(call->{
|
||||
Context c=call.getArgument(0);assertThat(c.stage()).isEqualTo(Stage.AFTER_LOADING);
|
||||
var os=outbounds("WAIT_OUTBOUND");var ds=deliveries(c,"WAIT_DELIVERY");
|
||||
for(int n=0;n<os.size();n++) {
|
||||
var o=os.get(n);var d=ds.get(n);
|
||||
f.db.update("INSERT INTO ll_outbound_order(id,customer_id,dispatch_order_id,outbound_no,status,package_count)VALUES(?,?,?,?,?,?)",6001+n,o.customerId(),c.dispatchId(),o.outboundNo(),o.status(),o.packageCount());
|
||||
f.db.update("INSERT INTO ll_delivery_order(id,customer_id,dispatch_order_id,outbound_order_id,outbound_no,delivery_no,status,package_count)VALUES(?,?,?,?,?,?,?,?)",7001+n,o.customerId(),c.dispatchId(),6001+n,o.outboundNo(),d.deliveryNo(),d.status(),d.packageCount());
|
||||
}
|
||||
return new ArrangementFact("AUTO-ARR","business-hash",c.cargoHash(),c.loadingCompletionHash(),new ArrangementResult(number,c.recordVersion()+1,"AUTO-ARR",c.cargoHash(),Instant.now(),os,ds));
|
||||
});
|
||||
when(delivery.departureFacts(any(),eq(true))).thenAnswer(call->{
|
||||
Context c=call.getArgument(0);f.db.queryForList("SELECT id FROM ll_outbound_order ORDER BY id FOR UPDATE");f.db.queryForList("SELECT id FROM ll_delivery_order ORDER BY id FOR UPDATE");
|
||||
return new DepartureFacts(number,"AUTO-ARR",c.cargoHash(),c.loadingCompletionHash(),"business-hash",outbounds("WAIT_OUTBOUND"),deliveries(c,"WAIT_DELIVERY"),c.cargo(),new Gate(true,null,null));
|
||||
});
|
||||
when(delivery.markDeparted(any(),any(),any(),anyString())).thenAnswer(call->{
|
||||
Context c=call.getArgument(0);f.db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED'");f.db.update("UPDATE ll_delivery_order SET status='DELIVERING'");
|
||||
return new DepartureResult(outbounds("OUTBOUNDED"),deliveries(c,"DELIVERING"),8);
|
||||
});
|
||||
var ledger=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json));
|
||||
var hashes=new DispatchCommandHasher(f.json);
|
||||
var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class));
|
||||
var departure=new DispatchDepartureService(f.service,f.repository,sources,delivery,sourceWorkflow,f.fleet,ledger,hashes,f.json);
|
||||
service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes);
|
||||
}
|
||||
/** 每次请求沿用最初真实测量和版本,便于验证竞争及原键恢复。 */
|
||||
DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,measurements,"实装发车");}
|
||||
/** 两客户子出库保持独立业务号和箱数。 */
|
||||
List<OutboundReference> outbounds(String status){return List.of(new OutboundReference("101","客户101","OUT-A",status,3,1),new OutboundReference("202","客户202","OUT-B",status,5,1));}
|
||||
/** 两目的地仍对应各自真实客户,不把整车合并为单客户。 */
|
||||
List<DeliveryReference> deliveries(Context c,String status){return List.of(new DeliveryReference("GA","101","客户101","OUT-A","DL-A",c.cargo().get(0).destination(),status,3,1),new DeliveryReference("GB","202","客户202","OUT-B","DL-B",c.cargo().get(1).destination(),status,5,1));}
|
||||
/** 各线程独立身份和数据库事务竞争同一整车父锁。 */
|
||||
boolean tryComplete(CountDownLatch start,Actor actor,String key) throws Exception {
|
||||
authorize();try {start.await(5,TimeUnit.SECONDS);f.tx.execute(s->service.complete(actor,number,request(key)));return true;}
|
||||
catch(com.baiyue.common.core.exception.BizException expected){return false;}
|
||||
finally {SecurityContextHolder.clearContext();}
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -13,6 +13,32 @@ import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** 统一PDA读取实际多客户主单和逐项扫码,仓限制在SQL完成。 */
|
||||
class DispatchPdaReadPersistenceTest {
|
||||
/** 默认PDA待办包含已通过调度的待理货整车,资源取同一冻结快照。 */
|
||||
@Test void pendingTallyReviewIsVisibleWithResourcesButDraftAndRejectedAreNot() throws Exception {
|
||||
var security=org.springframework.security.core.context.SecurityContextHolder.getContext();var previous=security.getAuthentication();
|
||||
security.setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("picker","","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
|
||||
try {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pending-read",List.of(f.source(1,"101",501,"ONT8",3)))));
|
||||
f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT)");
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchWholePdaReadMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(f.db.getDataSource());factory.setConfiguration(config);factory.afterPropertiesSet();
|
||||
var queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service);
|
||||
var actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
|
||||
assertThat(f.tx.<List<DispatchViews.PdaTask>>execute(s->queries.list(actor,null))).isEmpty();
|
||||
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null)));
|
||||
assertThat(f.tx.<List<DispatchViews.PdaTask>>execute(s->queries.list(actor,null))).isEmpty();
|
||||
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null)));
|
||||
var list=f.tx.execute(s->queries.list(actor,null));assertThat(list).hasSize(1);
|
||||
var task=list.get(0);assertThat(task.stage()).isEqualTo(Stage.BEFORE_LOADING);
|
||||
assertThat(task.actions().get(Action.LOADING_REVIEW).allowed()).isTrue();assertThat(task.actions().get(Action.PICK).allowed()).isFalse();
|
||||
assertThat(task.resources().vehicle().plate()).isEqualTo("TEST-TRUCK");assertThat(task.resources().driver().name()).isEqualTo("测试司机");
|
||||
f.tx.execute(s->f.service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"tally-reject",Decision.REJECTED,"车辆不符")));
|
||||
assertThat(f.tx.<List<DispatchViews.PdaTask>>execute(s->queries.list(actor,null))).isEmpty();
|
||||
assertThat(f.tx.execute(s->queries.task(actor,created.dispatchNo())).reviews().loadingReview().reason()).isEqualTo("车辆不符");
|
||||
} finally {security.setAuthentication(previous);}
|
||||
}
|
||||
|
||||
/** 同仓两个客户、两个目的地只返回一个整车作业,别仓不能借单号读取。 */
|
||||
@Test void readsOneTruckAcrossCustomersAndRejectsOtherWarehouseNumber() throws Exception {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5);
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.authentication.TestingAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import java.util.List;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** 审核两端共用真实持久主单,驳回回客服且不能复用上一轮批准。 */
|
||||
class DispatchReviewWorkflowPersistenceTest {
|
||||
/** 客服、调度、理货队列互斥,任一级驳回都只回客服队列。 */
|
||||
@Test void eachReviewRejectionReturnsOnlyToCustomerServiceQueue() throws Exception {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("queues",List.of(f.source(1,"101",501,"ONT8",3)))));
|
||||
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO);
|
||||
var submitted=submit(f,created,"submit-1");assertQueue(f,created.dispatchNo(),BeforeLoadingSection.DISPATCH_REVIEW);
|
||||
var rejected=review(f,f.actor,submitted,false,"dispatch-reject",Decision.REJECTED," 车辆图片不清楚 ");
|
||||
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO);
|
||||
assertThat(f.service.detail(f.actor,created.dispatchNo()).reviews().dispatchReview().reason()).isEqualTo("车辆图片不清楚");
|
||||
var resubmitted=submit(f,rejected,"submit-2");
|
||||
var approved=review(f,f.actor,resubmitted,false,"dispatch-approve",Decision.APPROVED,null);
|
||||
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.LOADING_REVIEW);
|
||||
var tallyRejected=review(f,f.actor,approved,true,"loading-reject",Decision.REJECTED,"司机不符");
|
||||
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO);
|
||||
var detail=f.service.detail(f.actor,created.dispatchNo());
|
||||
assertThat(detail.reviews().loadingReview().reason()).isEqualTo("司机不符");
|
||||
assertThat(detail.reviews().dispatchReview().status()).isEqualTo(ReviewStatus.NOT_SUBMITTED);
|
||||
assertThatThrownBy(()->review(f,f.actor,tallyRejected,true,"cannot-skip-dispatch",Decision.APPROVED,null)).hasMessageContaining("状态已变化");
|
||||
var third=submit(f,tallyRejected,"submit-3");assertQueue(f,created.dispatchNo(),BeforeLoadingSection.DISPATCH_REVIEW);
|
||||
detail=f.service.detail(f.actor,created.dispatchNo());
|
||||
assertThat(detail.reviews().dispatchReview().reviewRound()).isEqualTo(3);
|
||||
assertThat(detail.reviews().loadingReview().reason()).isNull();
|
||||
assertThatThrownBy(()->review(f,f.actor,third,true,"cannot-skip-again",Decision.APPROVED,null)).hasMessageContaining("状态已变化");
|
||||
}
|
||||
|
||||
/** 两级拒绝都须真实原因;失败不写版本、审核历史或命令结果。 */
|
||||
@Test void blankRejectionReasonIsRejectedAtBothLevelsWithoutWrites() throws Exception {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("reason",List.of(f.source(1,"101",501,"ONT8",3)))));
|
||||
var submitted=submit(f,created,"submit");
|
||||
assertThatThrownBy(()->review(f,f.actor,submitted,false,"blank-dispatch",Decision.REJECTED," \n ")).hasMessageContaining("驳回原因不能为空");
|
||||
assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(submitted.recordVersion());
|
||||
var approved=review(f,f.actor,submitted,false,"approve",Decision.APPROVED,null);
|
||||
assertThatThrownBy(()->review(f,f.actor,approved,true,"blank-loading",Decision.REJECTED,null)).hasMessageContaining("驳回原因不能为空");
|
||||
assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(approved.recordVersion());
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key LIKE 'blank-%'",Long.class)).isZero();
|
||||
}
|
||||
|
||||
/** 客服多次改车货保留本轮驳回原因,重新提交才开始全新审核轮次。 */
|
||||
@Test void cargoChangesKeepRejectionReasonUntilNextSubmission() throws Exception {
|
||||
for(boolean loading:List.of(false,true)) {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("retain",List.of(f.source(1,"101",501,"ONT8",3)))));
|
||||
var current=submit(f,created,"submit");
|
||||
if(loading)current=review(f,f.actor,current,false,"approve",Decision.APPROVED,null);
|
||||
var rejected=review(f,f.actor,current,loading,"reject",Decision.REJECTED,"请重新核对货物");
|
||||
var added=f.tx.execute(s->f.service.addCargo(f.actor,created.dispatchNo(),new DispatchCommands.AddCargo(rejected.recordVersion(),"add",List.of(f.source(2,"101",502,"ONT8",2)),List.of())));
|
||||
var addedAgain=f.tx.execute(s->f.service.addCargo(f.actor,created.dispatchNo(),new DispatchCommands.AddCargo(added.recordVersion(),"add-again",List.of(f.source(3,"101",503,"ONT8",1)),List.of())));
|
||||
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=3",Long.class);
|
||||
var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,new DispatchCommands.ReasonCommand(addedAgain.recordVersion(),"remove","调整目的仓")));
|
||||
var resources=f.configured("updated-resources",List.of());
|
||||
var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正")));
|
||||
var detail=f.service.detail(f.actor,created.dispatchNo());
|
||||
var retained=loading?detail.reviews().loadingReview():detail.reviews().dispatchReview();
|
||||
assertThat(detail.reviewState()).isEqualTo(ReviewState.DRAFT);
|
||||
assertThat(retained.status()).isEqualTo(ReviewStatus.REJECTED);assertThat(retained.reason()).isEqualTo("请重新核对货物");
|
||||
assertThat(retained.basisHash()).isNull();assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO);
|
||||
submit(f,updated,"resubmit");detail=f.service.detail(f.actor,created.dispatchNo());
|
||||
assertThat(detail.reviews().dispatchReview().status()).isEqualTo(ReviewStatus.PENDING);
|
||||
assertThat(detail.reviews().dispatchReview().reason()).isNull();assertThat(detail.reviews().loadingReview().reason()).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
/** PDA理货共享后台版本;成功恢复不重复写,越仓、失权和替换身份均拒绝。 */
|
||||
@Test void pdaReviewSharesVersionAndOriginalRecoveryWithAdmin() throws Exception {
|
||||
var security=SecurityContextHolder.getContext();var previous=security.getAuthentication();
|
||||
try {
|
||||
security.setAuthentication(new TestingAuthenticationToken("picker","","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
var pda=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pda-review",List.of(f.source(1,"101",501,"ONT8",3)))));
|
||||
var submitted=submit(f,created,"submit");
|
||||
assertThat(f.service.actions(pda,f.repository.find(created.dispatchNo())).get(Action.LOADING_REVIEW).allowed()).isFalse();
|
||||
assertThatThrownBy(()->review(f,pda,submitted,false,"pda-dispatch",Decision.APPROVED,null)).hasMessageContaining("仅当前绑定仓");
|
||||
var approved=review(f,f.actor,submitted,false,"dispatch",Decision.APPROVED,null);
|
||||
assertThat(f.service.actions(pda,f.repository.find(created.dispatchNo())).get(Action.LOADING_REVIEW).allowed()).isTrue();
|
||||
assertThatThrownBy(()->review(f,new DispatchContracts.Actor("picker",ActorMode.PDA,"99"),approved,true,"wrong-warehouse",Decision.APPROVED,null)).hasMessageContaining("当前绑定仓");
|
||||
var loaded=review(f,pda,approved,true,"pda-approve",Decision.APPROVED,null);
|
||||
assertThat(loaded.stage()).isEqualTo(Stage.LOADING);
|
||||
assertThatThrownBy(()->review(f,f.actor,approved,true,"admin-stale",Decision.APPROVED,null)).hasMessageContaining("版本已变化");
|
||||
assertThat(review(f,pda,approved,true,"pda-approve",Decision.APPROVED,null).replayed()).isTrue();
|
||||
assertThat(f.tx.execute(s->f.service.command(pda,created.dispatchNo(),"pda-approve")).replayed()).isTrue();
|
||||
assertThatThrownBy(()->f.tx.execute(s->f.service.command(new DispatchContracts.Actor("picker",ActorMode.PDA,"99"),created.dispatchNo(),"pda-approve"))).hasMessageContaining("当前绑定仓");
|
||||
assertThatThrownBy(()->f.tx.execute(s->f.service.command(new DispatchContracts.Actor("other",ActorMode.PDA,"9"),created.dispatchNo(),"pda-approve"))).hasMessageContaining("无权");
|
||||
security.setAuthentication(new TestingAuthenticationToken("picker","","lastleg:pda:operate"));
|
||||
assertThatThrownBy(()->review(f,pda,approved,true,"pda-approve",Decision.APPROVED,null)).hasMessageContaining("仅当前绑定仓");
|
||||
assertThatThrownBy(()->f.tx.execute(s->f.service.command(pda,created.dispatchNo(),"pda-approve"))).hasMessageContaining("原动作权限");
|
||||
assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(loaded.recordVersion());
|
||||
} finally {security.setAuthentication(previous);}
|
||||
}
|
||||
|
||||
/** 使用服务真实SQL分页和全量统计验证当前队列,不以返回动作猜测队列。 */
|
||||
private static void assertQueue(DispatchAggregatePersistenceTest.Fixture f,String number,BeforeLoadingSection expected) {
|
||||
for(var section:BeforeLoadingSection.values()) {
|
||||
var page=f.tx.execute(s->f.service.page(f.actor,new DispatchViews.Query(1,20,number,null,null,null,null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,section,null)));
|
||||
assertThat(page.total()).isEqualTo(section==expected?1:0);
|
||||
var counts=page.statistics().beforeLoadingReviewCounts();
|
||||
assertThat(counts.vehicleInfoTotal()).isEqualTo(expected==BeforeLoadingSection.VEHICLE_INFO?1:0);
|
||||
assertThat(counts.dispatchReview().pending()).isEqualTo(expected==BeforeLoadingSection.DISPATCH_REVIEW?1:0);
|
||||
assertThat(counts.loadingReview().pending()).isEqualTo(expected==BeforeLoadingSection.LOADING_REVIEW?1:0);
|
||||
}
|
||||
}
|
||||
|
||||
/** 每次提交以真实返回版本开始下一轮,不人工造审核状态。 */
|
||||
private static DispatchContracts.CommandResult submit(DispatchAggregatePersistenceTest.Fixture f,DispatchContracts.CommandResult prior,String key) {
|
||||
return f.tx.execute(s->f.service.submitReview(f.actor,prior.dispatchNo(),new DispatchCommands.SubmitReview(prior.recordVersion(),key,null)));
|
||||
}
|
||||
|
||||
/** 通过同一审核服务分别调用两端,版本和幂等键始终由请求明确提供。 */
|
||||
private static DispatchContracts.CommandResult review(DispatchAggregatePersistenceTest.Fixture f,DispatchContracts.Actor actor,DispatchContracts.CommandResult prior,boolean loading,String key,Decision decision,String reason) {
|
||||
return f.tx.execute(s->f.service.review(actor,prior.dispatchNo(),loading,new DispatchCommands.ReviewCommand(prior.recordVersion(),key,decision,reason)));
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -72,6 +72,18 @@ class PdaOutboundDispatchContractTest {
|
||||
verify(outboundService).pdaTasks("900", null);
|
||||
}
|
||||
|
||||
/** PDA只暴露理货审核且身份固定取绑定仓,不提供调度审核入口。 */
|
||||
@Test
|
||||
void loadingReviewUsesTheSharedServiceWithBoundPdaIdentity() {
|
||||
var workflow=mock(com.baiyue.lastleg.dispatch.service.DispatchAggregateService.class);
|
||||
var controller=new DispatchPdaController();ReflectionTestUtils.setField(controller,"workflow",workflow);
|
||||
var user=pda(Set.of("lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"),900L);
|
||||
var request=new DispatchCommands.ReviewCommand(2L,"pda-review",DispatchTypes.Decision.REJECTED,"司机资料待核对");
|
||||
controller.loadingReview(user,"PC202610210001",request);
|
||||
verify(workflow).review(new DispatchContracts.Actor("7",DispatchTypes.ActorMode.PDA,"900"),"PC202610210001",true,request);
|
||||
assertFalse(Arrays.stream(DispatchPdaController.class.getDeclaredMethods()).anyMatch(method->method.getName().equals("dispatchReview")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingRolePermissionOrWarehouseBindingIsRejected() {
|
||||
DispatchPdaController controller = new DispatchPdaController();
|
||||
@@ -88,7 +100,10 @@ class PdaOutboundDispatchContractTest {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-pda-operations-v1.yaml"),
|
||||
StandardCharsets.UTF_8);
|
||||
assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches:"));
|
||||
assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:"));
|
||||
assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review:"));
|
||||
assertTrue(yaml.contains("overseas-business-v1.yaml#/components/schemas/DispatchReviewCommand"));
|
||||
assertFalse(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:"));
|
||||
assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:"));
|
||||
assertTrue(yaml.contains("/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:"));
|
||||
assertTrue(yaml.contains("/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pallets/{palletNo}/pick:"));
|
||||
assertFalse(yaml.contains("name: customerId"));
|
||||
|
||||
Reference in New Issue
Block a user