This commit is contained in:
2026-10-21 14:30:41 +08:00
parent 0af977bdef
commit 40a03978cd
53 changed files with 1004 additions and 1291 deletions
+2 -2
View File
@@ -57,7 +57,7 @@
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` |
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` |
| 平台排车查询、整车四块详情、审核与实载 | `dispatch/controller/DispatchQueryController.java`、`DispatchAggregateController.java`;公开契约`dispatch/port/`,业务规则见`../../docs/海外仓业务/排车列表统一作业改造契约.md` |
| 草稿、双端装发与派送安排 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`,派送组合`delivery/controller/DeliveryTransferController.java` |
| 草稿、双端装车发运 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`;自动发运编排`dispatch/service/DispatchLoadingDepartureService.java` |
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` |
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java` |
| 一件代发接收、SKU/箱唛库存 | `dropship/controller/DropshipOrderController.java` |
@@ -77,7 +77,7 @@
| 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java` 的提交及两级审核;同一排车先调度审核、再理货审核,通过后才允许真实扫码。资源、货物或路线关键变更使原依据失效;零扫码显式退回后重新审核,不复用旧客户子单审核状态 |
| 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 |
| 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 |
| 实物装载、逐段容量与双端整车发车 | `dispatch/pda/controller/DispatchPdaController.java` → `dispatch/service/DispatchAggregateService.java`(装车)、`DispatchDepartureService.java`(发车);平台与PDA均使用本端`dispatches/{dispatchNo}/loading-complete`及`/depart`,公共DTO在`dispatch/port/`,已有单写入唯一`recordVersion`。装车前须真实PDA拣货核平,发车前须全车派送安排完整;装车及发车不重复执行扫码已完成的库存SHIP,Fleet容量和资源资格继续由真实提供者复核 |
| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,同一事务复用实载确认、派送单准备及发车,成功直接`DELIVERING`;请求携带唯一`recordVersion`,原键恢复最终结果。公开手工安排派送、`/depart`入口已移除;内部`AFTER_LOADING`和步骤命令只用于事务编排。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 |
| 车次货物结案、车辆和司机分别释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 |
| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` |
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` |
+2 -2
View File
@@ -95,9 +95,9 @@
- 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。
- 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。
- 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。
- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车,装车完成后、实际发车前由后台安排派送,按客户与目的仓拆分派送单。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。
- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车;Admin/PDA确认`loading-complete`时,同一事务自动按客户与目的仓生成派送单并发车,成功直接进入`DELIVERING`。公开手工安排派送及`/depart`入口已删除;内部`AFTER_LOADING`仅是同事务中间态。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。
- 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积必须来自实际确认,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车、安排派送和发车不重复执行已完成的库存扣减。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积必须来自实际确认,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。
- 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。
- 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。
@@ -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")
@@ -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());
@@ -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)
@@ -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("""
@@ -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));
}
/** 原动作权限与绑定仓同时核对未知结果,不按单客户任务恢复。 */
@@ -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() { }
@@ -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,
@@ -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) {
@@ -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])));
@@ -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));
}
}
@@ -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绑定仓的一件代发出库任务
@@ -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,
@@ -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);
@@ -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"));
@@ -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);
@@ -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();}
}
}
}
@@ -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);
@@ -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)));
}
}
@@ -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"));