Compare commits

...
Author SHA1 Message Date
ouyangxq 1ea7b4ff12 优化 2026-10-21 17:26:41 +08:00
ouyangxq 0976b6ef0d 优化 2026-10-21 17:22:19 +08:00
ouyangxq 0c6c55f6f1 优化 2026-10-21 17:08:52 +08:00
ouyangxq 4a0e86bd42 优化 2026-10-21 16:58:30 +08:00
26 changed files with 1557 additions and 501 deletions
+1 -1
View File
@@ -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,不把计划值当实测值,资源/计划/关联变更后须重新核对 |
| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,同一事务复用实载确认、派送单准备及发车,成功直接`DELIVERING`;请求携带唯一`recordVersion`,原键恢复最终结果。公开手工安排派送、`/depart`入口已移除;内部`AFTER_LOADING`和步骤命令只用于事务编排。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 |
| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,实载确认成功进入`AFTER_LOADING`;请求携带唯一`recordVersion`,原键恢复原装车后结果。`dispatch/service/DispatchLoadingDocumentService.java`复用整车附加照片与账本,平台/PDA均经`loading-documents`保存;五框齐全的最后一次绑定与派送准备、发车同事务,成功`DELIVERING`。公开手工安排派送、`/depart`入口仍不开放。真实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` |
@@ -28,6 +28,10 @@
`fleet.ownedbusiness`只为原排车装车后的自有车辆组合运营资料。五条精确引用限`OwnedBusinessModels→DispatchViews`、`OwnedBusinessProjection→DispatchContracts`、`OwnedBusinessQueryService→DispatchContracts/DispatchTypes/DispatchDetailReader`,均为`dispatch.port`公开事实或只读应用端口;不增加`fleet→dispatch-delivery`通用许可,不共享原排车实体、Mapper或具体Service。原详情适配器在dispatch内部委托唯一提供者。`DispatchAggregateService→OwnedBusinessOwnershipCapture`仅在原装车事务捕获归属,不能反向调用运营查询;捕获失败随原确认回滚。反向合同拒绝具体持久化/Service引用、其他消费者借用端口许可及任何包循环。
### 装车资料触发派送边界(2026-10-21)
`DispatchLoadingDocumentService`、`DispatchLoadingDepartureService`及平台/PDA两个装车资料Controller只引用`fleet.ownedbusiness.port.DispatchLoadingPhotoPort`的公开照片、版本、绑定请求和结果。`DispatchLoadingPhotoAdapter`在车队内部复用原附加表、上传账本和文件校验,不反向调用排车服务。排车服务持整车父锁,在装车确认或资料保存后核对两项条件,同一事务推进派送并记录原结果。结构门禁仅开放上述四类到此端口的精确引用,不开放车队Service、Repository或Mapper,也不新增包循环。
### 外配业务原排车详情读取边界(2026-10-21)
`OutsourcedBusinessModels → DispatchViews` 仅组合既有 `dispatch.port` 公开详情。依赖登记与已有外配反向合同同步,不开放 `fleet→dispatch-delivery` 通用许可;原排车实体、Mapper、具体 Service 及其他消费者仍由门禁拒绝。
@@ -72,6 +72,11 @@ test('fleet stays a business slice and its policies have no infrastructure depen
&& edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'
|| edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService'
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture'
|| ['com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService',
'com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService',
'com.baiyue.lastleg.dispatch.controller.DispatchLoadingDocumentController',
'com.baiyue.lastleg.dispatch.pda.controller.PdaDispatchLoadingDocumentController'].includes(edge.source)
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort'
|| [
'TransportTaskModels->pickup.FleetPickupModels', 'TransportTaskModels->trip.FleetTripModels',
'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged',
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.dispatch.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory;
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.*;
import com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
/** 平台排车装车资料入口,与另一端共用五框和自动派送门禁。 */
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents")
public class DispatchLoadingDocumentController {
private final DispatchLoadingDocumentService service;
/** Controller只负责会话与输入映射,所有资料和状态规则归服务。 */
public DispatchLoadingDocumentController(DispatchLoadingDocumentService service){this.service=service;}
/** 读取本车已保存照片,不以设备缓存作为上传事实。 */
@GetMapping
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<Documents> get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
return ApiResponse.success(service.get(DispatchActorFactory.platform(user),dispatchNo));
}
/** 上传先记录原请求,成功回执后再保存本车槽位引用。 */
@PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
return ApiResponse.success(service.upload(DispatchActorFactory.platform(user),dispatchNo,slot,idempotencyKey,file));
}
/** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */
@PutMapping("/{slot}")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<Documents> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
@PathVariable Slot slot,@Valid @RequestBody BindPhoto request) {
return ApiResponse.success(service.bind(DispatchActorFactory.platform(user),dispatchNo,slot,request));
}
/** 原上传仅允许当前有权限的原操作者恢复。 */
@GetMapping("/uploads/{idempotencyKey}")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<Photo> recover(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
return ApiResponse.success(service.recover(DispatchActorFactory.platform(user),dispatchNo,idempotencyKey));
}
/** 每次预览核对本车当前绑定,文件不得跨车跨仓读取。 */
@GetMapping("/{slot}/files/{fileId}/content")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,@PathVariable @Positive long fileId) {
var value=service.content(DispatchActorFactory.platform(user),dispatchNo,slot,fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(value.photo().contentType()))
.header("X-Content-Type-Options","nosniff").body(value.content());
}
}
@@ -98,7 +98,7 @@ public interface DispatchAggregateMapper {
estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt},
loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey},
arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt},
delivery_completed_at=#{r.deliveryCompletedAt},picked_package_count=#{r.pickedPackageCount},
delivery_completed_at=#{r.deliveryCompletedAt},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount},
shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt}
WHERE id=#{r.id} AND record_version=#{version}
""")
@@ -93,6 +93,11 @@ public interface DispatchSourceMapper {
@Select("SELECT p.id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE p.id=#{id} AND ip.customer_id=#{customer} FOR UPDATE")
Long lockPlanPallet(@Param("customer") String customer, @Param("id") Long id);
/** 原计划完成实收后才能把预排估算替换为实际整托数量,避免收货中提前锁定半托。 */
@Select("SELECT COUNT(*) FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id "
+ "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PENDING_RECONCILIATION')")
int receivedPlanCompleted(@Param("customer") String customer,@Param("id") Long id);
/** 退回来源仍使用本票实际实收,不借普通入仓别名。 */
@Select("SELECT id FROM ll_return_stock_receipt WHERE id=#{id} AND customer_id=#{customer} FOR UPDATE")
Long lockReturn(@Param("customer") String customer, @Param("id") Long id);
@@ -57,7 +57,7 @@ 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(loadingDeparture.complete(DispatchActorFactory.pda(user),dispatchNo,request));
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.dispatch.pda.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory;
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.*;
import com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
/** 绑定仓PDA装车资料入口,与另一端共用五框和自动派送门禁。 */
@RestController
@Validated
@RequestMapping("/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents")
public class PdaDispatchLoadingDocumentController {
private final DispatchLoadingDocumentService service;
/** Controller只负责会话与输入映射,所有资料和状态规则归服务。 */
public PdaDispatchLoadingDocumentController(DispatchLoadingDocumentService service){this.service=service;}
/** 读取本车已保存照片,不以设备缓存作为上传事实。 */
@GetMapping
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public ApiResponse<Documents> get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
return ApiResponse.success(service.get(DispatchActorFactory.pda(user),dispatchNo));
}
/** 上传先记录原请求,成功回执后再保存本车槽位引用。 */
@PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
return ApiResponse.success(service.upload(DispatchActorFactory.pda(user),dispatchNo,slot,idempotencyKey,file));
}
/** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */
@PutMapping("/{slot}")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public ApiResponse<Documents> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
@PathVariable Slot slot,@Valid @RequestBody BindPhoto request) {
return ApiResponse.success(service.bind(DispatchActorFactory.pda(user),dispatchNo,slot,request));
}
/** 原上传仅允许当前有权限的原操作者恢复。 */
@GetMapping("/uploads/{idempotencyKey}")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public ApiResponse<Photo> recover(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
return ApiResponse.success(service.recover(DispatchActorFactory.pda(user),dispatchNo,idempotencyKey));
}
/** 每次预览核对本车当前绑定,文件不得跨车跨仓读取。 */
@GetMapping("/{slot}/files/{fileId}/content")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,@PathVariable @Positive long fileId) {
var value=service.content(DispatchActorFactory.pda(user),dispatchNo,slot,fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(value.photo().contentType()))
.header("X-Content-Type-Options","nosniff").body(value.content());
}
}
@@ -74,6 +74,8 @@ public class DispatchSourceRepository {
public Long lockOrder(String customer, Long id) { return source.lockOrder(customer,id); }
/** 锁定真实计划托盘根。 */
public Long lockPlan(String customer, Long id) { return source.lockPlanPallet(customer,id); }
/** 只读核对原计划已经结束实际收货。 */
public boolean receivedPlanCompleted(String customer,Long id){return source.receivedPlanCompleted(customer,id)==1;}
/** 锁定实际退回收据根。 */
public Long lockReturn(String customer, Long id) { return source.lockReturn(customer,id); }
/** 真实库存数量及版本。 */
@@ -540,7 +540,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);
@@ -3,13 +3,14 @@ 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 com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort;
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;
@@ -17,38 +18,52 @@ public class DispatchLoadingDepartureService {
private final DispatchDepartureService departure;
private final DispatchCommandLedger commands;
private final DispatchCommandHasher hashes;
private final DispatchLoadingPhotoPort documents;
/** 复用装车、派送和发车的真实规则,不另建库存或运输状态实现。 */
public DispatchLoadingDepartureService(DispatchAggregateService workflow,DispatchDeliveryPort delivery,
DispatchDepartureService departure,DispatchCommandLedger commands,DispatchCommandHasher hashes) {
DispatchDepartureService departure,DispatchCommandLedger commands,DispatchCommandHasher hashes,
DispatchLoadingPhotoPort documents) {
this.workflow=workflow;this.delivery=delivery;this.departure=departure;this.commands=commands;this.hashes=hashes;
this.documents=documents;
}
/** 原键恢复先于阶段校验;任一步失败均回滚装载、子单、费用、发车与全部账本。 */
/** 原请求记录最终阶段;重放不重复装车、创建派送或发车。 */
@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 original=workflow.replay(context,actor,identity);if(original.isPresent())return original.get();
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 ready=DispatchLoadingDocumentService.complete(documents.read(context.dispatchId()).photos());
var finalState=ready?departAfterDocuments(actor,number,request.idempotencyKey()):loaded;
var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),finalState.recordVersion(),
finalState.status(),finalState.stage(),finalState.occurredAt(),finalState.effect(),finalState.counts(),loaded.result(),false,false);
commands.append(context.dispatchId(),actor,identity,result);
return result;
}
/** 仅供装车资料齐全的保存事务调用;派送准备和发车失败回滚最后一张绑定。 */
@Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED)
public CommandResult departAfterDocuments(Actor actor,String number,String key) {
var context=workflow.lockForArrangement(actor,number);
if(context.stage()!=Stage.AFTER_LOADING || context.status()!=Status.ACTIVE)
throw new BizException(ResultCode.CONFLICT,"当前阶段不能按装车资料推进派送");
// 先取得本车单据锁再申请真实来源锁,后续所有步骤保持同一个整车父锁。
delivery.departureFacts(context,true);
String arrangementKey=stepKey(number,key,"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))
stepKey(number,key,"DEPART"),"五张装车照片已齐全,自动进入派送中"));
if(departed.stage()!=Stage.DELIVERING)
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;
return departed;
}
/** 固定长度内部键绑定原请求和步骤,不向客户端暴露中间命令作为恢复入口。 */
@@ -0,0 +1,85 @@
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.DispatchContracts.Actor;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Context;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort;
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.*;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.*;
/** Admin/PDA按整车共用装车资料;通过车队公开端口保存照片,不读取其持久化。 */
@Service
public class DispatchLoadingDocumentService {
public static final List<Slot> SLOTS=List.of(Slot.values());
private final DispatchAggregateService workflow;
private final DispatchLoadingDepartureService departure;
private final DispatchLoadingPhotoPort photos;
/** 装车资料与履约原子编排留在排车域,车队只提供照片事实。 */
public DispatchLoadingDocumentService(DispatchAggregateService workflow,DispatchLoadingDepartureService departure,DispatchLoadingPhotoPort photos) {
this.workflow=workflow;this.departure=departure;this.photos=photos;
}
/** GET只读,PDA每次按可信主体重验冻结仓。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Documents get(Actor actor,String number) {
require(actor,false);var context=context(actor,number,false);return documents(context,photos.read(context.dispatchId()),allowed(actor,true));
}
/** 上传先记原请求意图,不提前绑定或推进派送。 */
public Photo upload(Actor actor,String number,Slot slot,String key,MultipartFile file) {
require(actor,true);var context=context(actor,number,false);return photos.upload(context.dispatchId(),actor.operatorId(),slot,key,file);
}
/** 原操作者仍须有权限且满足本车绑定仓范围,不能跨账号恢复。 */
public Photo recover(Actor actor,String number,String key) {
require(actor,true);var context=context(actor,number,false);return photos.recover(context.dispatchId(),actor.operatorId(),key);
}
/** 锁顺序为车次、排车、附加行;第五张绑定与派送发车共同提交或回滚。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public Documents bind(Actor actor,String number,Slot slot,BindPhoto request) {
require(actor,true);var context=context(actor,number,true);
var replay=photos.replayBind(context.dispatchId(),actor.operatorId(),slot,request);if(replay!=null)return replay;
var state=photos.bind(context.dispatchId(),actor.operatorId(),slot,request);
if(context.stage()==Stage.AFTER_LOADING && complete(state.photos())) {
departure.departAfterDocuments(actor,number,request.idempotencyKey());context=workflow.preview(actor,number);
}
var result=documents(context,state,true);
photos.recordBind(context.dispatchId(),actor.operatorId(),slot,request,result);return result;
}
/** 当前引用由照片提供者再次核验,拒绝未绑定或其他车文件。 */
@Transactional(readOnly=true)
public Download content(Actor actor,String number,Slot slot,long fileId) {
require(actor,false);var context=context(actor,number,false);return photos.content(context.dispatchId(),slot,fileId);
}
/** 装车中可同步上传;后续阶段仍须有真实装载证据,仓隔离沿用主流程。 */
private Context context(Actor actor,String number,boolean lock) {
var value=lock?workflow.lockForArrangement(actor,number):workflow.preview(actor,number);
if(value.status()!=Status.ACTIVE || !Set.of(Stage.LOADING,Stage.AFTER_LOADING,Stage.DELIVERING,Stage.DELIVERED).contains(value.stage())
|| value.stage()!=Stage.LOADING && (value.loadingCompletedAt()==null || value.loadingCompletionHash()==null))
throw new BizException(ResultCode.CONFLICT,"进入装车中后才能管理装车资料");
return value;
}
/** 对外同时返回资料版本与当前排车阶段,二者不互相替代。 */
private Documents documents(Context context,State state,boolean editable) {
return new Documents(context.dispatchNo(),state.recordVersion(),Long.toString(context.recordVersion()),context.stage().name(),editable,complete(state.photos()),state.photos());
}
/** 按五个固定框核齐,不能用总数或重复槽位代替。 */
static boolean complete(List<Photo> photos) {
return SLOTS.stream().allMatch(slot->photos.stream().anyMatch(p->p.slot()==slot && p.fileId()!=null && !p.fileId().isBlank()));
}
/** 平台list与edit分离,PDA使用既有作业角色和绑定仓。 */
private void require(Actor actor,boolean write) {
if(actor==null || actor.operatorId()==null || !allowed(actor,write))throw new BizException(ResultCode.FORBIDDEN,"无装车资料操作权限");
}
/** 权限取当前会话,不为PDA补造平台权限。 */
private boolean allowed(Actor actor,boolean write) {
var auth=SecurityContextHolder.getContext().getAuthentication();if(auth==null || actor==null)return false;
var authorities=auth.getAuthorities().stream().map(a->a.getAuthority()).toList();
return actor.mode()==ActorMode.PDA?actor.boundWarehouseId()!=null && authorities.contains("lastleg:pda:operate")
&& authorities.contains("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")
:authorities.contains("platform:overseas-warehouse:dispatch:"+(write?"edit":"list"));
}
}
@@ -87,7 +87,7 @@ public class DispatchPdaQueryService {
if(!sourceValid)actions.put(Action.PICK,new Gate(false,"SOURCE_COMPOSITION_CHANGED","换标来源实际位置或累计剩余额度已变化,请回平台核对"));
if(!complete)actions.put(Action.LOADING_COMPLETE,new Gate(false,"PICKING_INCOMPLETE","逐项真实拣货来源尚未核平"));
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()));
var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),detail.stream().mapToLong(DispatchViews.PdaItem::scheduledPackages).sum(),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),workflow.resources(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions)));
}
@@ -95,36 +95,11 @@ public class DispatchPdaQueryService {
}
/** 预排首次扫码前只解析原计划实收仓位,不在GET补写实托;真正绑定仍由pick锁内重验。 */
private List<DispatchViews.Position> receivedPlanPositions(Actor actor,DispatchItem item,List<DispatchItem> items) {
if(!Objects.equals(actor.boundWarehouseId(),item.getOverseasWarehouseId()))return List.of();
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
if(rows.size()!=1)return List.of();
var actual=rows.get(0);
if(!"RECEIVED".equals(actual.sourceStage()) || actual.warehousePalletId()==null
|| !Objects.equals(actual.planPalletId(),item.getPlanPalletId())
|| !Objects.equals(actual.customerId(),item.getCustomerId())
|| !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId())
|| !Objects.equals(actual.packageCount(),item.getPackageCount())
|| !Objects.equals(actual.destinationType(),item.getDestinationType())
|| !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode())
|| !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))return List.of();
var originalOrders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
if(originalOrders.isEmpty() || !new HashSet<>(originalOrders).containsAll(
sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return List.of();
// 同托跨客户、换标及整申请来源必须全部已在原车;不能把新合托货物暴露为原计划可拣库存。
var group=sourceRules.selectionGroup(actor.boundWarehouseId(),new DispatchCommands.CargoSelection(
SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null));
if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return List.of();
for(var member:group.sources()) {
var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null);
if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId())
|| n(selected.getPackageCount())!=member.packages())return List.of();
}
var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false);
if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),actor.boundWarehouseId())
|| !Objects.equals(p.palletId(),actual.warehousePalletId()))
|| stock.stream().mapToLong(p->p.availablePackages()).sum()!=n(item.getPackageCount()))return List.of();
return stock.stream().filter(p->p.availablePackages()>0)
.map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList();
var received=DispatchReceivedPlan.resolve(actor.boundWarehouseId(),item,items,sources,sourceRules);
if(received.isEmpty())return List.of();
// 只修改本次投影对象,不在GET更新排车、版本或库存。
item.setPackageCount(received.get().source().packageCount());
return received.get().positions();
}
/** 仓和操作者均来自受信身份,不接收请求自报。 */
private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");}
@@ -27,6 +27,7 @@ public class DispatchPickingService {
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
@jakarta.annotation.Resource private DispatchService sourceRules;
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.FleetTripExecutionService fleet;
private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources;
private final DispatchPickingRepository picking;private final DispatchPickOrderAttributionService attribution;
private final InventoryCommandService inventory;private final ReturnRelabelDispatchStock returns;
@@ -101,33 +102,44 @@ public class DispatchPickingService {
new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Picked(itemId,request.positionId(),returnCartonId,request.packages(),before+request.packages(),complete),false,false);
commands.append(row.getId(),actor,identity,result);return result;
}
/** 原计划自然收货只补齐同一来源实托,数量/客户/目的地变化必须回到平台核对。 */
/** 首拣将本车已实收原计划按真实整托数量绑定,保持原审核身份并与扣货同事务回滚。 */
private void materialize(com.baiyue.lastleg.dispatch.entity.DispatchAggregate row,DispatchItem item,Actor actor,List<DispatchItem> items) {
long dispatch=row.getId();
if(item.getWarehousePalletId()!=null) {
relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));return;
}
if(item.getPlanPalletId()==null)throw conflict("本来源尚未关联真实实托");
var orders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
if(orders.isEmpty())throw conflict("原计划缺少真实订单组成");
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
if(rows.size()!=1)throw conflict("请先完成本来源实际入仓收货");var actual=rows.get(0);
if(actual.warehousePalletId()==null || !Objects.equals(actual.customerId(),item.getCustomerId()) || !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId())
|| !Objects.equals(actual.packageCount(),item.getPackageCount()) || !Objects.equals(actual.destinationType(),item.getDestinationType())
|| !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode()) || !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))throw conflict("原计划实收数量、仓或目的地已变化,请先回平台核对");
if(!new HashSet<>(orders).containsAll(sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))throw conflict("实托新增了未审核的来源订单");
item.setWarehousePalletId(actual.warehousePalletId());
var received=new LinkedHashMap<DispatchItem,DispatchReceivedPlan.Fact>();
for(var candidate:items)DispatchReceivedPlan.resolve(actor.boundWarehouseId(),candidate,items,sources,sourceRules)
.ifPresent(fact->received.put(candidate,fact));
if(!received.containsKey(item))throw conflict("该托盘尚未完成可拣实收,或原来源、目的地、库存占用已变化,请刷新核对");
// 同车同实托的多个原计划一次绑定,避免首拣把其他已审核成员误判为新增货物。
for(var entry:received.entrySet()) {
entry.getKey().setWarehousePalletId(entry.getValue().source().warehousePalletId());
entry.getKey().setPackageCount(entry.getValue().source().packageCount());
}
relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
var fresh=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
if(fresh.size()!=1 || !Objects.equals(actual.warehousePalletId(),fresh.get(0).warehousePalletId()) || !Objects.equals(actual.packageCount(),fresh.get(0).packageCount()))throw conflict("原计划实托组成已变化,请回平台核对");
var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);sources.stock(item.getCustomerId(),actual.warehousePalletId(),true);
var pallets=new ArrayList<Long>();repository.cargo(dispatch).stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).forEach(pallets::add);pallets.add(actual.warehousePalletId());
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at).get(actual.warehousePalletId());
try{if(sources.materialize(dispatch,item.getCustomerId(),item.getPlanPalletId(),actual.warehousePalletId(),physical,at)!=1)throw conflict("原计划来源占用已变化");}
catch(DuplicateKeyException occupied){throw conflict("实托已经被其他来源安排占用");}
item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED");
if(picking.materialize(item)!=1)throw conflict("本来源实际实托未保存");
for(var candidate:received.keySet().stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
if(sources.lockPlan(candidate.getCustomerId(),candidate.getPlanPalletId())==null)throw conflict("原计划来源已变化");
var fresh=sources.selectedPlan(candidate.getCustomerId(),candidate.getPlanPalletId());
if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(candidate,fresh.get(0))
|| !Objects.equals(candidate.getWarehousePalletId(),fresh.get(0).warehousePalletId())
|| !Objects.equals(candidate.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请刷新后重新扫码");
}
var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);
var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().sorted().toList();
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at);
for(var entry:received.entrySet()) {
var candidate=entry.getKey();var actual=entry.getValue().source();
try{if(sources.materialize(dispatch,candidate.getCustomerId(),candidate.getPlanPalletId(),actual.warehousePalletId(),physical.get(actual.warehousePalletId()),at)!=1)throw conflict("原计划来源占用已变化");}
catch(DuplicateKeyException occupied){throw conflict("实托已经被其他排车占用");}
candidate.setPalletNo(actual.palletNo());candidate.setSourceStage("RECEIVED");
if(picking.materialize(candidate)!=1)throw conflict("本来源实际实托未保存");
}
row.setPackageCount(Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum()));
var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop(
i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key()))
.collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
fleet.updateDispatchCargo(row.getDispatchNo(),row.getPackageCount(),stops);
}
/** 库存幂等键分别标识一次实际动作,业务单号始终使用本车号。 */
private static InventoryCommandRequests.Move move(DispatchCommands.Pick request,String dispatch,String reference,String key){return new InventoryCommandRequests.Move(request.positionId(),request.packages(),reference,dispatch,null,key,request.remark());}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
import java.util.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType;
/** 预排来源只冻结货物身份;自然实收的逐托箱数由真实库存提供,查询和首拣共用此规则。 */
final class DispatchReceivedPlan {
record Fact(DispatchPalletCandidate source, List<DispatchViews.Position> positions) { }
/** 仅解析原车完整来源,禁止把新客户、新订单或冻结货物混入实收数量。 */
static Optional<Fact> resolve(String warehouse, DispatchItem item, List<DispatchItem> items,
DispatchSourceRepository sources, DispatchService rules) {
if(item.getWarehousePalletId()!=null || item.getPlanPalletId()==null
|| !Objects.equals(warehouse,item.getOverseasWarehouseId()))return Optional.empty();
if(!sources.receivedPlanCompleted(item.getCustomerId(),item.getPlanPalletId()))return Optional.empty();
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
if(rows.size()!=1)return Optional.empty();
var actual=rows.get(0);
if(!sameIdentity(item,actual) || actual.packageCount()==null || actual.packageCount()<=0)return Optional.empty();
var original=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
if(original.isEmpty() || !new HashSet<>(original).containsAll(
sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return Optional.empty();
var group=rules.selectionGroup(warehouse,new DispatchCommands.CargoSelection(
SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null));
if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return Optional.empty();
for(var member:group.sources()) {
var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null);
if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId()))return Optional.empty();
// 原计划的估算箱数不限制实收;已经绑定或换标的冻结贡献仍严格核对。
if((selected.getWarehousePalletId()!=null || selected.getPlanPalletId()==null)
&& (selected.getPackageCount()==null || selected.getPackageCount().longValue()!=member.packages()))return Optional.empty();
}
var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false);
if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),warehouse)
|| !Objects.equals(p.palletId(),actual.warehousePalletId()) || p.holdPackages()!=0
|| p.allocatedPackages()!=0 || p.pickedPackages()!=0)
|| stock.stream().mapToLong(p->p.availablePackages()).sum()!=actual.packageCount())return Optional.empty();
return Optional.of(new Fact(actual,stock.stream().filter(p->p.availablePackages()>0)
.map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList()));
}
/** 箱数允许随自然实收变化,客户、原计划、起运仓和目的地必须保持原身份。 */
static boolean sameIdentity(DispatchItem item,DispatchPalletCandidate actual) {
return "RECEIVED".equals(actual.sourceStage()) && actual.warehousePalletId()!=null
&& Objects.equals(actual.planPalletId(),item.getPlanPalletId())
&& Objects.equals(actual.customerId(),item.getCustomerId())
&& Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId())
&& Objects.equals(actual.destinationType(),item.getDestinationType())
&& Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode())
&& Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress());
}
}
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.fleet.ownedbusiness.port;
import jakarta.validation.constraints.*;
import java.util.List;
import org.springframework.web.multipart.MultipartFile;
/** 整车装车资料公开端口;排车只组合资料事实,不读取车队持久化实现。 */
public interface DispatchLoadingPhotoPort {
enum Slot { LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT }
record Photo(Slot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {}
record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {}
record State(String recordVersion,List<Photo> photos) {}
record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String stage,boolean editable,boolean complete,List<Photo> photos) {}
record Download(Photo photo,byte[] content) {}
/** 只读取原整车附加资料版本与五框,不初始化记录。 */
State read(long dispatchId);
/** 上传保存原请求意图;不提前计入已保存照片。 */
Photo upload(long dispatchId,String operator,Slot slot,String key,MultipartFile file);
/** 仅原操作者可恢复原上传结果。 */
Photo recover(long dispatchId,String operator,String key);
/** 先校验原槽位、完整载荷与操作者;无原成功命令时返回null。 */
Documents replayBind(long dispatchId,String operator,Slot slot,BindPhoto request);
/** 调用者已持排车父锁;保存照片、资料版本及历史但不单独提交事务。 */
State bind(long dispatchId,String operator,Slot slot,BindPhoto request);
/** 与绑定及自动发车共同提交完整原结果。 */
void recordBind(long dispatchId,String operator,Slot slot,BindPhoto request,Documents result);
/** 文件只可从本车当前已绑定槽位下载。 */
Download content(long dispatchId,Slot slot,long fileId);
}
@@ -0,0 +1,124 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.time.Instant;
import java.util.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
/** 在原照片表和命令账本实现装车资料端口,不反向调用排车履约服务。 */
@Service
public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort {
private final OwnedBusinessRepository repository;
private final OwnedBusinessCommandService commands;
private final OwnedBusinessProjection projection;
private final OwnedBusinessSupport codec;
private final OwnedBusinessFileGateway files;
/** 复用现有照片、版本、历史及文件归属校验。 */
public DispatchLoadingPhotoAdapter(OwnedBusinessRepository repository,OwnedBusinessCommandService commands,
OwnedBusinessProjection projection,OwnedBusinessSupport codec,OwnedBusinessFileGateway files) {
this.repository=repository;this.commands=commands;this.projection=projection;this.codec=codec;this.files=files;
}
public record Binding(Slot slot,BindPhoto request) {}
/** GET只读;首次保存前资料版本为0。 */
@Override public State read(long id) {
var row=repository.record(id);
return new State(row==null?"0":row.getRecordVersion().toString(),projection.photos(row).stream()
.filter(p->"LOADING".equals(p.category())).map(this::publicPhoto).toList());
}
/** 先落稳定上传意图,再请求File;未知结果不允许换键重传。 */
@Override public Photo upload(long id,String operator,Slot slot,String key,MultipartFile multipart) {
codec.key(key);var originalSlot=slot(slot);
if(multipart==null || multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB");
byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException failure){throw bad("无法读取待上传照片");}
var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes);
HttpOwnedBusinessFileGateway.validate(payload);
var identity=new OwnedBusinessPhotoService.UploadIdentity(originalSlot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes));
var reservation=commands.reserveUpload(id,operator(operator),key,identity);
if(!reservation.created())return uploaded(reservation.command());
try {
var result=photo(originalSlot,files.upload(id,originalSlot,operator,payload));
commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return publicPhoto(result);
} catch(RuntimeException failure) {
try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,"DISPATCH_LOADING_UPLOAD_UNKNOWN");}
catch(RuntimeException recording){failure.addSuppressed(recording);}
throw conflict("照片上传结果未知,请保留原请求键核对");
}
}
/** 上传结果仅限本车原操作者及装车槽位。 */
@Override public Photo recover(long id,String operator,String key) {
codec.key(key);var command=commands.existing(id,key,operator(operator));
if(command==null)throw new BizException(ResultCode.NOT_FOUND,"未找到本车原上传请求");return uploaded(command);
}
/** 完整原结果先于当前资料版本比较,防止重复推进。 */
@Override public Documents replayBind(long id,String operator,Slot slot,BindPhoto request) {
validate(slot,request);var old=commands.existing(id,request.idempotencyKey(),operator(operator));
if(old==null)return null;
commands.same(old,"BIND_LOADING_DOCUMENT",codec.version(request.recordVersion()),new Binding(slot,request));
if(!"SUCCEEDED".equals(old.getStatus()))throw conflict("原资料保存结果尚未确认");return codec.read(old.getResultJson(),Documents.class);
}
/** 父排车锁由调用者持有;附加版本竞争与最后一张发车失败沿外层事务回滚。 */
@Override @Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED)
public State bind(long id,String operator,Slot slot,BindPhoto request) {
validate(slot,request);long version=codec.version(request.recordVersion());var originalSlot=slot(slot);
var verified=photo(originalSlot,files.metadata(id,originalSlot,codec.version(request.fileId())));
var seed=new OwnedBusinessRows.Record();seed.setId(IdWorker.getId());seed.setDispatchId(id);
seed.setCreatedBy(operator);seed.setUpdatedBy(operator);seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt());
var row=repository.lock(seed);
if(row==null || row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("装车资料已被其他操作更新,请刷新后核对");
var photos=new EnumMap<PhotoSlot,OwnedBusinessModels.Photo>(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p);
photos.put(originalSlot,new OwnedBusinessModels.Photo(originalSlot,originalSlot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()));
row.setPhotosJson(codec.write(List.copyOf(photos.values())));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now()));
if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败");
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(id);history.setRecordVersion(row.getRecordVersion());
history.setAction("BIND_LOADING_DOCUMENT");history.setOperatorId(operator);history.setReason("保存装车照片 "+(slot.ordinal()+1));history.setOccurredAt(row.getUpdatedAt());
history.setSnapshotJson(codec.write(new OwnedBusinessModels.Snapshot(projection.operations(row),projection.photos(row),operator)));repository.history(history);
return read(id);
}
/** 保存调用者提供的最终阶段,命令与资料、派送同事务提交。 */
@Override @Transactional(propagation=Propagation.MANDATORY)
public void recordBind(long id,String operator,Slot slot,BindPhoto request,Documents result) {
commands.success(id,operator(operator),request.idempotencyKey(),"BIND_LOADING_DOCUMENT",codec.version(request.recordVersion()),new Binding(slot,request),result);
}
/** 下载仅允许当前已保存引用,不接受未绑定上传或其他车文件。 */
@Override public Download content(long id,Slot slot,long fileId) {
var originalSlot=slot(slot);var photo=projection.photos(repository.record(id)).stream()
.filter(p->p.slot()==originalSlot && String.valueOf(fileId).equals(p.fileId())).findFirst()
.orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车未绑定该装车照片"));
return new Download(publicPhoto(photo),files.content(id,originalSlot,fileId));
}
/** 原五框技术名保持文件归属,拒绝油耗框作为装车资料。 */
private PhotoSlot slot(Slot slot){if(slot==null)throw bad("请选择有效的装车照片框");return PhotoSlot.valueOf(slot.name());}
/** 版本0合法,文件编号必须为正且请求键不得缺失。 */
private void validate(Slot slot,BindPhoto request) {
slot(slot);if(request==null)throw bad("照片绑定请求缺失");codec.key(request.idempotencyKey());codec.version(request.recordVersion());
if(codec.version(request.fileId())<=0)throw bad("文件编号无效");
}
/** 账本只消费可信操作者;平台/PDA权限在排车入口复核。 */
private OwnedBusinessModels.Actor operator(String operator){return new OwnedBusinessModels.Actor(operator,operator,Set.of());}
/** 回执必须是已知成功上传,其他类别或未明确结果不得充数。 */
private Photo uploaded(OwnedBusinessRows.Command command) {
if(!"UPLOAD_PHOTO".equals(command.getAction()) || !"SUCCEEDED".equals(command.getStatus()) || command.getResultJson()==null)throw conflict("原上传结果尚未确认,请保留原请求");
var value=codec.read(command.getResultJson(),OwnedBusinessModels.Photo.class);
if(!"LOADING".equals(value.category()))throw bad("原上传不是装车照片");return publicPhoto(value);
}
/** 外部事实仅投影照片字段,不暴露持久化记录。 */
private Photo publicPhoto(OwnedBusinessModels.Photo photo) {
return new Photo(Slot.valueOf(photo.slot().name()),photo.category(),photo.fileId(),photo.fileName(),photo.contentType(),photo.sizeBytes(),photo.uploadedAt(),photo.savedAt());
}
/** 上传回执不伪造保存时间。 */
private OwnedBusinessModels.Photo photo(PhotoSlot slot,OwnedBusinessFileGateway.Evidence value) {
return new OwnedBusinessModels.Photo(slot,slot.category(),Long.toString(value.fileId()),value.fileName(),value.contentType(),Long.toString(value.sizeBytes()),value.uploadedAt(),null);
}
}
@@ -0,0 +1,394 @@
openapi: 3.0.3
info:
title: 排车装车资料(Admin/PDA共用)
version: 1.0.0
description: LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位仅表示照片1至5,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。
security:
- bearerAuth: []
paths:
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents:
get:
operationId: getDispatchLoadingDocuments
summary: 读取本车装车资料或原上传结果
x-permission-code: platform:overseas-warehouse:dispatch:list
parameters:
- $ref: '#/components/parameters/dispatchNo'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Documents'
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents:
get:
operationId: getDispatchLoadingDocumentsPda
summary: 读取本车装车资料或原上传结果
x-permission-code: lastleg:pda:operate
parameters:
- $ref: '#/components/parameters/dispatchNo'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Documents'
x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}/uploads:
post:
operationId: uploadDispatchLoadingPhoto
summary: 上传整车装车照片,尚未绑定
x-permission-code: platform:overseas-warehouse:dispatch:edit
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/slot'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Photo'
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required:
- file
- idempotencyKey
properties:
file:
type: string
format: binary
description: 非空JPG/PNG,最大10MiB
idempotencyKey:
type: string
minLength: 8
maxLength: 128
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}/uploads:
post:
operationId: uploadDispatchLoadingPhotoPda
summary: 上传整车装车照片,尚未绑定
x-permission-code: lastleg:pda:operate
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/slot'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Photo'
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required:
- file
- idempotencyKey
properties:
file:
type: string
format: binary
description: 非空JPG/PNG,最大10MiB
idempotencyKey:
type: string
minLength: 8
maxLength: 128
x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}:
put:
operationId: bindDispatchLoadingPhoto
summary: 保存装车照片;五框齐全时自动进入派送中
x-permission-code: platform:overseas-warehouse:dispatch:edit
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/slot'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Documents'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BindPhoto'
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}:
put:
operationId: bindDispatchLoadingPhotoPda
summary: 保存装车照片;五框齐全时自动进入派送中
x-permission-code: lastleg:pda:operate
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/slot'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Documents'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BindPhoto'
x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}/files/{fileId}/content:
get:
operationId: readDispatchLoadingPhoto
summary: 读取本车装车资料或原上传结果
x-permission-code: platform:overseas-warehouse:dispatch:list
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/slot'
- $ref: '#/components/parameters/fileId'
responses:
'200':
description: no-store图片内容
content:
image/jpeg:
schema:
type: string
format: binary
image/png:
schema:
type: string
format: binary
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}/files/{fileId}/content:
get:
operationId: readDispatchLoadingPhotoPda
summary: 读取本车装车资料或原上传结果
x-permission-code: lastleg:pda:operate
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/slot'
- $ref: '#/components/parameters/fileId'
responses:
'200':
description: no-store图片内容
content:
image/jpeg:
schema:
type: string
format: binary
image/png:
schema:
type: string
format: binary
x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents/uploads/{idempotencyKey}:
get:
operationId: recoverDispatchLoadingPhoto
summary: 读取本车装车资料或原上传结果
x-permission-code: platform:overseas-warehouse:dispatch:edit
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/idempotencyKey'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Photo'
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents/uploads/{idempotencyKey}:
get:
operationId: recoverDispatchLoadingPhotoPda
summary: 读取本车装车资料或原上传结果
x-permission-code: lastleg:pda:operate
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/idempotencyKey'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Photo'
x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
parameters:
dispatchNo:
name: dispatchNo
in: path
required: true
schema:
type: string
slot:
name: slot
in: path
required: true
schema:
type: string
enum: &id001
- LOADING_SCENE
- LOADING_LIST
- PRE_DEPARTURE_CHECK
- DRIVER_LICENSE
- RECEIPT
fileId:
name: fileId
in: path
required: true
schema:
type: string
idempotencyKey:
name: idempotencyKey
in: path
required: true
schema:
type: string
schemas:
Photo:
type: object
required:
- slot
- fileId
- fileName
- contentType
- sizeBytes
- uploadedAt
properties:
slot:
type: string
enum: *id001
category:
type: string
enum:
- LOADING
fileId:
type: string
fileName:
type: string
contentType:
type: string
sizeBytes:
type: string
uploadedAt:
type: string
savedAt:
type: string
nullable: true
Documents:
type: object
required:
- dispatchNo
- recordVersion
- editable
- photos
- dispatchRecordVersion
- stage
- complete
properties:
dispatchNo:
type: string
recordVersion:
type: string
editable:
type: boolean
photos:
type: array
items:
$ref: '#/components/schemas/Photo'
dispatchRecordVersion:
type: string
stage:
type: string
enum:
- LOADING
- AFTER_LOADING
- DELIVERING
- DELIVERED
complete:
type: boolean
description: 五个固定框各有一张已保存照片
BindPhoto:
type: object
required:
- recordVersion
- idempotencyKey
- fileId
properties:
recordVersion:
type: string
pattern: ^(0|[1-9][0-9]*)$
idempotencyKey:
type: string
minLength: 8
maxLength: 128
fileId:
type: string
@@ -1,379 +1,242 @@
{
"openapi": "3.0.3",
"info": {
"title": "排车车辆资料",
"version": "1.0.0",
"description": "仅平台有效排车DELIVERING/DELIVERED可用;兼容有无拼车货物,沿装车归属证据校验。共享原车队照片、版本、命令与历史,不新建附件存储。派送文件仍使用delivery-orders documents;不把车队RECEIPT照片当签收。列表权限只读,排车edit独立写;恢复还需list。"
},
"security": [
{
"bearerAuth": []
}
],
"paths": {
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents": {
"get": {
"operationId": "getDispatchVehicleDocuments",
"summary": "读取本车油耗图片;复用车队附加记录",
"x-permission-code": "platform:overseas-warehouse:dispatch:list",
"parameters": [
{
"$ref": "#/components/parameters/dispatchNo"
}
],
"responses": {
"200": {
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/Documents"
}
}
}
}
}
}
}
}
},
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/uploads": {
"post": {
"operationId": "uploadDispatchVehiclePhoto",
"summary": "准备本车油耗图片;尚未绑定保存",
"x-permission-code": "platform:overseas-warehouse:dispatch:edit",
"parameters": [
{
"$ref": "#/components/parameters/dispatchNo"
},
{
"$ref": "#/components/parameters/slot"
}
],
"responses": {
"200": {
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/Photo"
}
}
}
}
}
}
},
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": [
"file",
"idempotencyKey"
],
"properties": {
"file": {
"type": "string",
"format": "binary",
"description": "非空JPG/PNG,最大10MiB"
},
"idempotencyKey": {
"type": "string",
"minLength": 8,
"maxLength": 128
}
}
}
}
}
}
}
},
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}": {
"put": {
"operationId": "bindDispatchVehiclePhoto",
"summary": "按附加版本绑定原上传文件,不改变排车或签收状态",
"x-permission-code": "platform:overseas-warehouse:dispatch:edit",
"parameters": [
{
"$ref": "#/components/parameters/dispatchNo"
},
{
"$ref": "#/components/parameters/slot"
}
],
"responses": {
"200": {
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/Documents"
}
}
}
}
}
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BindPhoto"
}
}
}
}
}
},
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/files/{fileId}/content": {
"get": {
"operationId": "readDispatchVehiclePhoto",
"summary": "只读取本车当前绑定图片",
"x-permission-code": "platform:overseas-warehouse:dispatch:list",
"parameters": [
{
"$ref": "#/components/parameters/dispatchNo"
},
{
"$ref": "#/components/parameters/slot"
},
{
"$ref": "#/components/parameters/fileId"
}
],
"responses": {
"200": {
"description": "no-store图片内容",
"content": {
"image/jpeg": {
"schema": {
"type": "string",
"format": "binary"
}
},
"image/png": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
},
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/uploads/{idempotencyKey}": {
"get": {
"operationId": "recoverDispatchVehiclePhoto",
"summary": "只恢复当前操作者原油耗上传成功结果,未知结果拒绝重传",
"x-permission-code": "platform:overseas-warehouse:dispatch:edit",
"parameters": [
{
"$ref": "#/components/parameters/dispatchNo"
},
{
"$ref": "#/components/parameters/idempotencyKey"
}
],
"responses": {
"200": {
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/Photo"
}
}
}
}
}
}
}
}
}
},
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
},
"parameters": {
"dispatchNo": {
"name": "dispatchNo",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
"slot": {
"name": "slot",
"in": "path",
"required": true,
"schema": {
"type": "string",
"enum": [
"FUEL_START",
"FUEL_END"
]
}
},
"fileId": {
"name": "fileId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
"idempotencyKey": {
"name": "idempotencyKey",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
},
"schemas": {
"Photo": {
"type": "object",
"required": [
"slot",
"fileId",
"fileName",
"contentType",
"sizeBytes",
"uploadedAt"
],
"properties": {
"slot": {
"type": "string",
"enum": [
"FUEL_START",
"FUEL_END"
]
},
"category": {
"type": "string",
"enum": [
"FUEL"
]
},
"fileId": {
"type": "string"
},
"fileName": {
"type": "string"
},
"contentType": {
"type": "string"
},
"sizeBytes": {
"type": "string"
},
"uploadedAt": {
"type": "string"
},
"savedAt": {
"type": "string",
"nullable": true
}
}
},
"Documents": {
"type": "object",
"required": [
"dispatchNo",
"recordVersion",
"editable",
"photos"
],
"properties": {
"dispatchNo": {
"type": "string"
},
"recordVersion": {
"type": "string"
},
"editable": {
"type": "boolean"
},
"photos": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Photo"
}
}
}
},
"BindPhoto": {
"type": "object",
"required": [
"recordVersion",
"idempotencyKey",
"fileId"
],
"properties": {
"recordVersion": {
"type": "string",
"pattern": "^(0|[1-9][0-9]*)$"
},
"idempotencyKey": {
"type": "string",
"minLength": 8,
"maxLength": 128
},
"fileId": {
"type": "string"
}
}
}
}
}
}
openapi: 3.0.3
info:
title: 排车车辆资料
version: 1.0.0
description: 仅平台有效排车DELIVERING/DELIVERED可用;兼容有无拼车货物,沿装车归属证据校验。共享原车队照片、版本、命令与历史,不新建附件存储。派送文件仍使用delivery-orders
documents;不把车队RECEIPT照片当签收。列表权限只读,排车edit独立写;恢复还需list。
security:
- bearerAuth: []
paths:
/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents:
get:
operationId: getDispatchVehicleDocuments
summary: 读取本车油耗图片;复用车队附加记录
x-permission-code: platform:overseas-warehouse:dispatch:list
parameters:
- $ref: '#/components/parameters/dispatchNo'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Documents'
/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/uploads:
post:
operationId: uploadDispatchVehiclePhoto
summary: 准备本车油耗图片;尚未绑定保存
x-permission-code: platform:overseas-warehouse:dispatch:edit
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/slot'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Photo'
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required:
- file
- idempotencyKey
properties:
file:
type: string
format: binary
description: 非空JPG/PNG,最大10MiB
idempotencyKey:
type: string
minLength: 8
maxLength: 128
/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}:
put:
operationId: bindDispatchVehiclePhoto
summary: 按附加版本绑定原上传文件,不改变排车或签收状态
x-permission-code: platform:overseas-warehouse:dispatch:edit
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/slot'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Documents'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BindPhoto'
/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/files/{fileId}/content:
get:
operationId: readDispatchVehiclePhoto
summary: 只读取本车当前绑定图片
x-permission-code: platform:overseas-warehouse:dispatch:list
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/slot'
- $ref: '#/components/parameters/fileId'
responses:
'200':
description: no-store图片内容
content:
image/jpeg:
schema:
type: string
format: binary
image/png:
schema:
type: string
format: binary
/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/uploads/{idempotencyKey}:
get:
operationId: recoverDispatchVehiclePhoto
summary: 只恢复当前操作者原油耗上传成功结果,未知结果拒绝重传
x-permission-code: platform:overseas-warehouse:dispatch:edit
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/idempotencyKey'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Photo'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
parameters:
dispatchNo:
name: dispatchNo
in: path
required: true
schema:
type: string
slot:
name: slot
in: path
required: true
schema:
type: string
enum:
- FUEL_START
- FUEL_END
fileId:
name: fileId
in: path
required: true
schema:
type: string
idempotencyKey:
name: idempotencyKey
in: path
required: true
schema:
type: string
schemas:
Photo:
type: object
required:
- slot
- fileId
- fileName
- contentType
- sizeBytes
- uploadedAt
properties:
slot:
type: string
enum:
- FUEL_START
- FUEL_END
category:
type: string
enum:
- FUEL
fileId:
type: string
fileName:
type: string
contentType:
type: string
sizeBytes:
type: string
uploadedAt:
type: string
savedAt:
type: string
nullable: true
Documents:
type: object
required:
- dispatchNo
- recordVersion
- editable
- photos
properties:
dispatchNo:
type: string
recordVersion:
type: string
editable:
type: boolean
photos:
type: array
items:
$ref: '#/components/schemas/Photo'
BindPhoto:
type: object
required:
- recordVersion
- idempotencyKey
- fileId
properties:
recordVersion:
type: string
pattern: ^(0|[1-9][0-9]*)$
idempotencyKey:
type: string
minLength: 8
maxLength: 128
fileId:
type: string
@@ -1728,8 +1728,8 @@ paths:
type: string
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
post:
summary: 确认装车完成并自动进入派送中
description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。
summary: 确认装车完成,资料齐全时自动进入派送中
description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 装车中可通过loading-documents同步保存五张装车照片。原请求保存实载确认;成功action为LOADING_COMPLETE,资料已齐时stage为DELIVERING,未齐时为AFTER_LOADING,result保留真实Loaded装车证据。装车确认与资料齐全两项均完成时自动生成派送并发车;触发该次推进的装车确认或最后一次绑定与派送同事务回滚,先前已保存照片保留。重试装车原键保持原最终阶段结果,当前阶段另行读取。
security:
- bearerAuth: []
responses:
@@ -253,8 +253,8 @@ paths:
type: string
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
post:
summary: 确认装车完成并自动进入派送中
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。
summary: 确认装车完成,资料齐全时自动进入派送中
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 装车中可通过loading-documents同步保存五张装车照片。原请求保存实载确认;成功action为LOADING_COMPLETE,资料已齐时stage为DELIVERING,未齐时为AFTER_LOADING,result保留真实Loaded装车证据。装车确认与资料齐全两项均完成时自动生成派送并发车;触发该次推进的装车确认或最后一次绑定与派送同事务回滚,先前已保存照片保留。重试装车原键保持原最终阶段结果,当前阶段另行读取。
security:
- bearerAuth: []
responses:
@@ -1,6 +1,15 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.mapper.*;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.BindPhoto;
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Documents;
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot;
import com.baiyue.lastleg.fleet.ownedbusiness.mapper.*;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import java.nio.file.*;
import java.util.regex.Pattern;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.*;
@@ -19,43 +28,53 @@ 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 {
/** 装车完成不发车,第五张保存自动发车,旧装车键保持原装车后结果。 */
@Test void loadingWaitsForFivePhotosThenDepartsOnce() 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(result.action()).isEqualTo(Action.LOADING_COMPLETE);assertThat(result.stage()).isEqualTo(Stage.AFTER_LOADING);
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero();
for(int i=0;i<4;i++)assertThat(f.bind(i,f.f.actor).stage()).isEqualTo("AFTER_LOADING");
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero();
var fifth=f.bind(4,new Actor("picker",ActorMode.PDA,"9"));assertThat(fifth.stage()).isEqualTo("DELIVERING");
assertThat(fifth.complete()).isTrue();assertThat(fifth.photos()).hasSize(5);
var replayPhoto=f.f.tx.execute(s->f.documents.bind(new Actor("picker",ActorMode.PDA,"9"),f.number,Slot.RECEIPT,new BindPhoto("4","photo-bind-4","1004")));
assertThat(replayPhoto).isEqualTo(fifth);
assertThat(result.counts().shippedPackageCount()).isZero();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");
assertThat(f.f.db.queryForObject("SELECT result_json FROM ll_dispatch_command WHERE idempotency_key='complete'",String.class)).contains("AFTER_LOADING");
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);
assertThat(f.f.tx.execute(s->f.f.service.command(f.f.actor,f.number,"complete")).stage()).isEqualTo(Stage.AFTER_LOADING);
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 {
/** 派送失败回滚第五张绑定,保留已确认装车和前四张资料,原文件可重试。 */
@Test void preparationAndDepartureFailuresRollBackTheFifthPhoto() throws Exception {
authorize();
for(boolean failPreparation:List.of(true,false)) {
var f=new Fixture();
f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
for(int i=0;i<4;i++)f.bind(i,f.f.actor);
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();
assertThatThrownBy(()->f.bind(4,f.f.actor)).isInstanceOf(IllegalStateException.class);
var row=f.f.repository.find(f.number);assertThat(row.getStage()).isEqualTo("AFTER_LOADING");assertThat(row.getRecordVersion()).isEqualTo(f.version+1);
assertThat(row.getLoadingCompletionHash()).isNotNull();
assertThat(f.documents.get(f.f.actor,f.number).photos()).hasSize(4);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_command WHERE action IN ('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");
}
@@ -68,12 +87,12 @@ class DispatchLoadingDeparturePersistenceTest {
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);
var result=f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")));assertThat(result.stage()).isEqualTo(Stage.AFTER_LOADING);
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同时确认只有一条完整链提交,不会产生半车或重复派送。 */
/** 同一版本后台/PDA同时确认只有一次装车提交,资料未齐不发车。 */
@Test void concurrentAdminAndPdaCompleteOnlyOneDeparture() throws Exception {
authorize();var f=new Fixture();var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
try {
@@ -81,20 +100,99 @@ class DispatchLoadingDeparturePersistenceTest {
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());
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("AFTER_LOADING");
verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,never()).departWholeSources(any(),any(),any(),any());
}
/** 仅配置装车完成及PDA既有角色权限,不授予人工派送、发车权限。 */
/** 装车中可上传,装车前与跨仓拒绝,平台只读权限不能保存照片。 */
@Test void loadingDocumentsEnforceStageWarehouseAndWritePermission() throws Exception {
authorize();var f=new Fixture();
assertThat(f.documents.get(f.f.actor,f.number).stage()).isEqualTo("LOADING");
f.f.db.update("UPDATE ll_dispatch_order SET stage='BEFORE_LOADING'");
assertThatThrownBy(()->f.documents.get(f.f.actor,f.number)).hasMessageContaining("进入装车中后");
f.f.db.update("UPDATE ll_dispatch_order SET stage='LOADING'");
f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
assertThatThrownBy(()->f.documents.get(new Actor("picker",ActorMode.PDA,"99"),f.number)).hasMessageContaining("当前绑定仓");
assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,null,new BindPhoto("0","wrong-slot","1000")))).hasMessageContaining("装车照片框");
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("reader","","platform:overseas-warehouse:dispatch:list"));
assertThat(f.documents.get(f.f.actor,f.number).editable()).isFalse();
assertThatThrownBy(()->f.bind(0,f.f.actor)).hasMessageContaining("无装车资料操作权限");
}
/** 两端同版本竞争第五张,仅一个绑定和一次真实派送成功。 */
@Test void concurrentFifthPhotoCannotDispatchTwice() throws Exception {
authorize();var f=new Fixture();f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
for(int i=0;i<4;i++)f.bind(i,f.f.actor);
var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
try {
var results=new ArrayList<Future<Boolean>>();
for(String key:List.of("admin-last","pda-last"))results.add(workers.submit(()->{
authorize();try {start.await(5,TimeUnit.SECONDS);var actor=key.startsWith("pda")?new Actor("picker",ActorMode.PDA,"9"):f.f.actor;
f.f.tx.execute(s->f.documents.bind(actor,f.number,Slot.RECEIPT,new BindPhoto("4",key,"1004")));return true;
}catch(com.baiyue.common.core.exception.BizException expected){return false;}
finally {SecurityContextHolder.clearContext();}
}));
start.countDown();assertThat(List.of(results.get(0).get(25,TimeUnit.SECONDS),results.get(1).get(25,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);
} finally {workers.shutdownNow();}
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());
assertThat(f.documents.get(f.f.actor,f.number).photos()).hasSize(5);
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING");
}
/** 资料先齐不能提前发车;确认装车后直接派送,原键返回相同最终结果。 */
@Test void photosReadyBeforeLoadingConfirmationDepartAndReplayOnce() throws Exception {
authorize();var f=new Fixture();
for(int i=0;i<5;i++)assertThat(f.bind(i,f.f.actor).stage()).isEqualTo("LOADING");
verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());
var result=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("ready-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);
var replay=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("ready-complete")));
assertThat(replay.replayed()).isTrue();assertThat(replay.stage()).isEqualTo(Stage.DELIVERING);
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,"ready-complete")).stage()).isEqualTo(Stage.DELIVERING);
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());
verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
}
/** 资料先齐时派送失败回滚本次装车确认,五张已保存照片保留供原请求重试。 */
@Test void readyDocumentsSurviveFailedLoadingAndOriginalRequestCanRetry() throws Exception {
authorize();var f=new Fixture();for(int i=0;i<5;i++)f.bind(i,f.f.actor);
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("retry-ready")))).hasMessageContaining("模拟发运失败");
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("LOADING");
assertThat(f.documents.get(f.f.actor,f.number).complete()).isTrue();
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 idempotency_key='retry-ready'",Long.class)).isZero();
doNothing().when(f.sourceWorkflow).departWholeSources(any(),any(),any(),any());
assertThat(f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("retry-ready"))).stage()).isEqualTo(Stage.DELIVERING);
}
/** 最后一张照片与装车确认同时提交,不论谁先拿父锁均只发车一次。 */
@Test void lastPhotoAndLoadingConfirmationCanFinishConcurrently() throws Exception {
authorize();var f=new Fixture();for(int i=0;i<4;i++)f.bind(i,f.f.actor);
var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
try {
var load=workers.submit(()->f.tryComplete(start,f.f.actor,"concurrent-load"));
var photo=workers.submit(()->{authorize();try {start.await(5,TimeUnit.SECONDS);return f.bind(4,new Actor("picker",ActorMode.PDA,"9"));}finally{SecurityContextHolder.clearContext();}});
start.countDown();assertThat(load.get(25,TimeUnit.SECONDS)).isTrue();assertThat(photo.get(25,TimeUnit.SECONDS)).isNotNull();
} 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"));}
"platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit","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 DispatchLoadingDepartureService service;final DispatchLoadingDocumentService documents;final String number;final long version;final String manifest;
final List<LoadingMeasurement> measurements;
/** 创建两客户货物并走真实两级审核、真实拣货归属,不伪造已装车状态。 */
Fixture() throws Exception {
@@ -126,7 +224,23 @@ class DispatchLoadingDeparturePersistenceTest {
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);
f.session.getConfiguration().addMapper(OwnedBusinessMapper.class);
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String suffix:List.of("record","command","history")) {
var matcher=Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_"+suffix+" \\(.*?;").matcher(schema);
assertThat(matcher.find()).isTrue();
f.db.execute(matcher.group().replace(" JSON"," LONGTEXT").replace(" COLLATE utf8mb4_bin","").replaceFirst("(?s) ENGINE=InnoDB.*?;$",";"));
}
var records=new OwnedBusinessRepository(f.session.getMapper(OwnedBusinessMapper.class),mock(OwnedBusinessQueryMapper.class));
var codec=new OwnedBusinessSupport(f.json);var files=mock(OwnedBusinessFileGateway.class);
when(files.metadata(anyLong(),any(),anyLong())).thenAnswer(call->new OwnedBusinessFileGateway.Evidence(call.getArgument(2),"loading.png","image/png",12,"2026-10-21T00:00:00Z"));
var photos=new DispatchLoadingPhotoAdapter(records,new OwnedBusinessCommandService(records,codec),new OwnedBusinessProjection(codec),codec,files);
service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes,photos);
documents=new DispatchLoadingDocumentService(f.service,service,photos);
}
/** 每张照片保留独立版本与幂等键,复用真实附加记录保存事务。 */
Documents bind(int index,Actor actor) {
return f.tx.execute(s->documents.bind(actor,number,DispatchLoadingDocumentService.SLOTS.get(index),new BindPhoto(Integer.toString(index),"photo-bind-"+index,Long.toString(1000L+index))));
}
/** 每次请求沿用最初真实测量和版本,便于验证竞争及原键恢复。 */
DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,measurements,"实装发车");}
@@ -23,6 +23,44 @@ import static org.mockito.Mockito.*;
/** 根版本、幂等、真实客户作业与扫码持久化;库存/归属应用命令在此明确模拟,另由原域直接测试验证。 */
class DispatchPickingPersistenceTest {
/** 数量可自然同步不代表能接管其他车的实际托盘。 */
@Test void receivedPalletClaimedByAnotherDispatchCannotBePicked() throws Exception {
var fixture=new Picking(true);fixture.receivedPlans(9,11);
fixture.f.db.update("UPDATE ll_dispatch_physical_pallet_lock SET dispatch_order_id=999 WHERE warehouse_pallet_id=501");
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,
new DispatchCommands.Pick(fixture.version,"occupied-pallet",11L,9,0,null,null))))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("占用");
verify(fixture.inventory,never()).ship(any(),any(),any());
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10);
}
/** 预排两托10+10、实收9+11,首次扫码同步真实数量并保留原审核,原键重放不重复扣货。 */
@Test void receivedPalletCountsReplaceEstimatesAtomically() throws Exception {
var fixture=new Picking(true);fixture.receivedPlans(9,11);
var firstRequest=new DispatchCommands.Pick(fixture.version,"received-nine",11L,9,0,null,null);
var first=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,firstRequest));
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(9,11);
assertThat(fixture.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(20);
assertThat(fixture.f.db.queryForObject("SELECT review_state FROM ll_dispatch_order",String.class)).isEqualTo("APPROVED");
var replay=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,firstRequest));
assertThat(replay.replayed()).isTrue();
var second=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.secondItem,
new DispatchCommands.Pick(first.recordVersion(),"received-eleven",12L,11,0,null,null)));
assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue();
verify(fixture.inventory,times(2)).ship(anyString(),eq("picker"),any());
assertThat(fixture.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero();
}
/** 实收绑定后扣货失败必须同时回滚数量、绑定、总数和原审核。 */
@Test void receivedQuantityAndBindingRollBackWhenStockWriteFails() throws Exception {
var fixture=new Picking(true);fixture.receivedPlans(8,11);
doThrow(new IllegalStateException("库存命令失败")).when(fixture.inventory).ship(anyString(),anyString(),any());
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,
new DispatchCommands.Pick(fixture.version,"receipt-rollback",11L,8,0,null,null)))).isInstanceOf(IllegalStateException.class);
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10);
assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE warehouse_pallet_id IS NOT NULL",Long.class)).isZero();
assertThat(fixture.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(20);
assertThat(fixture.f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isEqualTo(fixture.version);
}
/** 原键在后续版本仍恢复原结果,不能重复调用SHIP或创建第二条真实扫码。 */
@Test void sameKeyShipsOnceAndLastPickDoesNotConfirmLoading() throws Exception {
var fixture=new Picking();var request=new DispatchCommands.Pick(fixture.version,"pda-pick-original",11L,3,6,null,"实际扫码");
@@ -68,6 +106,7 @@ class DispatchPickingPersistenceTest {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
final InventoryCommandService inventory=mock(InventoryCommandService.class);final DispatchPickOrderAttributionService attribution=mock(DispatchPickOrderAttributionService.class);
final DispatchPickingService service;final String number;final long item,version;final Long secondItem;
final DispatchSourceRepository sources;
final DispatchContracts.Actor actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
/** 建立真实客户来源、作业表、扫码表与可以参与回滚的库存行。 */
Picking() throws Exception {this(false);}
@@ -94,10 +133,33 @@ class DispatchPickingPersistenceTest {
doAnswer(call->{com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move move=call.getArgument(2);f.db.update("UPDATE ll_inventory_position SET available_packages=available_packages-?,version=version+1 WHERE id=? AND customer_id=?",move.packages(),move.positionId(),call.getArgument(0));return null;}).when(inventory).ship(anyString(),eq("picker"),any());
doAnswer(call->{String customer=call.getArgument(0);DispatchPdaPick pick=call.getArgument(1);long order="101".equals(customer)?9001L:9002L;long receipt="101".equals(customer)?1L:2L;f.db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity)VALUES(?,?,?,?,?,?,?)",pick.getId(),customer,pick.getId(),order,receipt,pick.getPackages(),pick.getProductQuantity());return List.of(new DispatchPickOrderAttributionService.OrderPackages(order,pick.getPackages()));})
.when(attribution).attribute(anyString(),any(DispatchPdaPick.class),isNull());
var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class));
sources=spy(new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)));
var commands=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json));
service=new DispatchPickingService(f.service,f.repository,sources,new DispatchPickingRepository(session.getMapper(DispatchPdaTaskMapper.class),session.getMapper(DispatchPdaPickMapper.class),session.getMapper(InventoryPositionMapper.class),session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),commands,new DispatchCommandHasher(f.json),f.json);
for(var field:List.of("physicalClaims","relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field));
org.springframework.test.util.ReflectionTestUtils.setField(service,"fleet",f.fleet);
}
/** 来源查询在此隔离,实收同步仍使用真实主单、明细、库存及车次事务。 */
void receivedPlans(int first,int second) {
f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN',package_count=10");
f.db.update("UPDATE ll_dispatch_order SET package_count=20");
var rules=(DispatchService)org.springframework.test.util.ReflectionTestUtils.getField(service,"sourceRules");
for(int n=1;n<=2;n++) {
long plan=n,pallet=500+n;String customer=n==1?"101":"102";int count=n==1?first:second;
f.candidates.get(plan).packageCount(count);
f.db.update("UPDATE ll_inventory_position SET available_packages=? WHERE id=?",count,10+n);
doReturn(List.of(f.candidates.get(plan))).when(sources).selectedPlan(customer,plan);
doReturn(true).when(sources).receivedPlanCompleted(customer,plan);
doReturn(List.of(9000+plan)).when(sources).sourceOrders(customer,null,plan);
doReturn(List.of(9000+plan)).when(sources).sourceOrders(customer,pallet,plan);
doReturn(plan).when(sources).lockPlan(customer,plan);
doReturn(1).when(sources).materialize(anyLong(),eq(customer),eq(plan),eq(pallet),anyLong(),any());
}
when(rules.selectionGroup(anyString(),any())).thenAnswer(call->{
var selected=(DispatchCommands.CargoSelection)call.getArgument(1);long plan=selected.planPalletId();var row=f.candidates.get(plan);
return new DispatchPlanningModels.SelectionGroup("group","version",List.of(new DispatchPlanningModels.SelectionGroupSource(
selected,"PLAN:"+plan,row.customerId(),row.customerName(),"INBOUND_PLAN","PLAN-"+plan,row.packageCount())),List.of(),true,List.of());
});
}
/** 使用实际实体属性建最小测试表,不修改或声称验证正式DDL。 */
private static String sqlType(Class<?> type){return type==Long.class?"BIGINT":type==Integer.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":"VARCHAR(20000)";}
@@ -17,6 +17,22 @@ import static org.mockito.ArgumentMatchers.*;
/** 真实PDA任务投影衔接原计划;来源提供者隔离,GET不得写绑定或库存。 */
class DispatchPlanPdaReadPersistenceTest {
/** 完成状态查询走真实Mapper,未完成或其他客户的计划不能被当作本托实收。 */
@Test void completionGateUsesOriginalCustomerAndPlan() throws Exception {
var f=new Fixture();
f.f.db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(64),status VARCHAR(64))");
f.f.db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR(64))");
f.f.db.update("INSERT INTO ll_inbound_plan VALUES(1,'101','IN_PROGRESS')");
f.f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(2,1,'101')");
var mapper=f.f.session.getMapper(com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.class);
assertThat(mapper.receivedPlanCompleted("101",2L)).isZero();
for(var status:List.of("COMPLETED","PENDING_RECONCILIATION")) {
f.f.db.update("UPDATE ll_inbound_plan SET status=?",status);
assertThat(mapper.receivedPlanCompleted("101",2L)).isOne();
assertThat(mapper.receivedPlanCompleted("other",2L)).isZero();
assertThat(mapper.receivedPlanCompleted("101",3L)).isZero();
}
}
/** 已实收原计划直接提供首拣仓位,查询不推进原车版本或写入实托。 */
@Test void receivedPlanExposesPositionWithoutMaterializing() throws Exception {
var f=new Fixture();var before=f.dbState();
@@ -28,15 +44,23 @@ class DispatchPlanPdaReadPersistenceTest {
verify(f.sources,never()).materialize(anyLong(),anyString(),anyLong(),anyLong(),anyLong(),any());
verify(f.rules,never()).lockExecutionSources(anyString(),anyList());
}
/** 在途、预留托盘和实收数量漂移均不成为可拣库存。 */
/** 在途和来源身份变化均不成为可拣库存。 */
@Test void unreceivedOrChangedPlanHasNoPositions() throws Exception {
var f=new Fixture();var row=f.f.candidates.get(1L);
row.sourceStage("PLAN");assertThat(f.read().items().get(0).positions()).isEmpty();
row.sourceStage("RECEIVED");row.packageCount(2);assertThat(f.read().items().get(0).positions()).isEmpty();
row.packageCount(3);row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty();
row.sourceStage("RECEIVED");row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty();
row.overseasWarehouseId("9");row.customerId("other");assertThat(f.read().items().get(0).positions()).isEmpty();
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
}
/** 自动预分配的10+10不约束实收9+11;查询给出实际数量且不改数据库。 */
@Test void plannedTenAndTenProjectsReceivedNineAndEleven() throws Exception {
var f=new Fixture(true);var before=f.dbState();
var task=f.read();
assertThat(task.items()).extracting(DispatchViews.PdaItem::scheduledPackages).containsExactly(9L,11L);
assertThat(task.items()).allSatisfy(i->assertThat(i.positions()).isNotEmpty());
assertThat(task.counts().packageCount()).isEqualTo(20);
assertThat(f.dbState()).isEqualTo(before);
}
/** 原计划不能吸收新的订单或未经原车审核的同托来源。 */
@Test void changedOrdersOrAdditionalPalletSourceBlocksFirstPick() throws Exception {
var f=new Fixture();
@@ -53,11 +77,18 @@ class DispatchPlanPdaReadPersistenceTest {
@Test void actualStockMustMatchWarehousePalletAndQuantity() throws Exception {
var f=new Fixture();
for(var stock:List.of(new Stock(11,21,501L,"99",3,0,0,0,1),
new Stock(11,21,502L,"9",3,0,0,0,1),new Stock(11,21,501L,"9",2,0,0,0,1))) {
new Stock(11,21,502L,"9",3,0,0,0,1),new Stock(11,21,501L,"9",2,0,0,0,1),
new Stock(11,21,501L,"9",3,1,0,0,1),new Stock(11,21,501L,"9",3,0,1,0,1))) {
when(f.sources.stock("101",501L,false)).thenReturn(List.of(stock));
assertThat(f.read().items().get(0).positions()).isEmpty();
}
}
/** 收货尚未结束时不把当前部分实收数量当成整托最终数量。 */
@Test void receivingPlanDoesNotExposePartialPallet() throws Exception {
var f=new Fixture();when(f.sources.receivedPlanCompleted("101",1L)).thenReturn(false);
assertThat(f.read().items().get(0).positions()).isEmpty();
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
}
/** 复用真实排车及PDA Mapper,模拟原计划来源快照。 */
private static final class Fixture {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
@@ -65,9 +96,13 @@ class DispatchPlanPdaReadPersistenceTest {
final DispatchService rules=mock(DispatchService.class);
final DispatchPdaQueryService queries;final String number;
/** 原车已审核,只清除自然收货尚未补写的实托字段。 */
Fixture() throws Exception {
var selection=f.source(1,"101",501,"ONT8",3);
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("plan-pda-read",List.of(selection))));
Fixture() throws Exception {this(false);}
/** 同一客户两托先预排再实收,模拟计划拆分与真实入托数量不同。 */
Fixture(boolean twoPallets) throws Exception {
var selection=f.source(1,"101",501,"ONT8",twoPallets?10:3);
var selections=new java.util.ArrayList<DispatchCommands.CargoSelection>();selections.add(selection);
if(twoPallets)selections.add(f.source(2,"101",502,"ONT8",10));
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("plan-pda-read",selections)));
f.approve(created);number=created.dispatchNo();
f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN'");
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)");
@@ -76,12 +111,28 @@ class DispatchPlanPdaReadPersistenceTest {
queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service);
ReflectionTestUtils.setField(queries,"sources",sources);ReflectionTestUtils.setField(queries,"sourceRules",rules);
when(sources.selectedPlan("101",1L)).thenReturn(List.of(f.candidates.get(1L)));
when(sources.receivedPlanCompleted(anyString(),anyLong())).thenReturn(true);
when(sources.sourceOrders("101",null,1L)).thenReturn(List.of(9001L));
when(sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L));
when(sources.stock("101",501L,false)).thenReturn(List.of(new Stock(11,21,501L,"9",3,0,0,0,1)));
when(rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup(
"group","version",List.of(new DispatchPlanningModels.SelectionGroupSource(
selection,"PLAN:1","101","测试客户","INBOUND_PLAN","PLAN-1",3)),List.of(),true,List.of()));
if(twoPallets) {
for(int n=1;n<=2;n++) {
long plan=n,pallet=500+n;int count=n==1?9:11;
f.candidates.get(plan).packageCount(count);
when(sources.selectedPlan("101",plan)).thenReturn(List.of(f.candidates.get(plan)));
when(sources.sourceOrders("101",null,plan)).thenReturn(List.of(9000+plan));
when(sources.sourceOrders("101",pallet,plan)).thenReturn(List.of(9000+plan));
when(sources.stock("101",pallet,false)).thenReturn(List.of(new Stock(10+n,20+n,pallet,"9",count,0,0,0,1)));
}
when(rules.selectionGroup(anyString(),any())).thenAnswer(call->{
var selected=(DispatchCommands.CargoSelection)call.getArgument(1);long plan=selected.planPalletId();
return new DispatchPlanningModels.SelectionGroup("group","version",List.of(new DispatchPlanningModels.SelectionGroupSource(
selected,"PLAN:"+plan,"101","测试客户","INBOUND_PLAN","PLAN-"+plan,plan==1?9:11)),List.of(),true,List.of());
});
}
}
/** 读取正式PDA入口。 */
DispatchViews.PdaTask read(){return f.tx.execute(s->queries.task(new DispatchContracts.Actor("picker",ActorMode.PDA,"9"),number));}
@@ -0,0 +1,76 @@
package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService;
import com.baiyue.lastleg.fleet.ownedbusiness.controller.DispatchVehicleDocumentController;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.server.ResponseStatusException;
import org.junit.jupiter.api.Test;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 排车资料复用同一车辆照片记录,不扩大旧入口或改变履约状态。 */
class DispatchVehicleDocumentTest {
/** 无拼车和实际拼车都能在派送后读取;原车队分类和冻结装车门禁继续生效。 */
@Test void dispatchScopeAcceptsBothCargoGroupsOnlyAfterDeparture() {
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();
var queries=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchDetailReader.class));
var source=OwnedBusinessFixtures.source();when(repo.source(source.getDispatchNo())).thenReturn(source);
assertThrows(BizException.class,()->queries.requireScoped(source.getDispatchNo(),OwnedBusinessScope.DISPATCH));
for(boolean pooled:List.of(false,true))for(String stage:List.of("DELIVERING","DELIVERED")) {
source.setPooledCargo(pooled);source.setStage(stage);
assertSame(source,queries.requireScoped(source.getDispatchNo(),OwnedBusinessScope.DISPATCH));
}
assertThrows(BizException.class,()->queries.requireOwned(source.getDispatchNo()));
source.setOwnedLoadingCompletionHash("other-load");
assertThrows(BizException.class,()->queries.requireScoped(source.getDispatchNo(),OwnedBusinessScope.DISPATCH));
verify(repo,never()).update(any(),anyLong());
}
/** 排车入口只接受油耗图片;签收和装车文件仍走派送单,旧外配仍不能上传油耗。 */
@Test void scopesKeepIndependentWritePermissionsAndPhotoCategories() {
var scope=OwnedBusinessScope.DISPATCH;
assertEquals("platform:overseas-warehouse:dispatch:list",scope.listPermission());
assertEquals("platform:overseas-warehouse:dispatch:edit",scope.uploadPermission());
for(var slot:PhotoSlot.values()) {
if(slot.category().equals("FUEL")) { assertDoesNotThrow(()->scope.requireSlot(slot));assertThrows(BizException.class,()->OwnedBusinessScope.OUTSOURCED.requireSlot(slot)); }
else assertThrows(BizException.class,()->scope.requireSlot(slot));
}
var actor=new Actor("reader","只读",Set.of(scope.listPermission()));
var row=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()).row(OwnedBusinessFixtures.source(),null,OwnedBusinessFixtures.cargo(),List.of(),actor,scope);
assertFalse(row.actions().upload().allowed());
}
/** 油耗与车队装车照片共存,排车补充油耗不破坏外配业务读取。 */
@Test void sharedFuelPhotosDoNotInvalidateOutsourcedProjection() {
var codec=OwnedBusinessFixtures.codec();var record=new OwnedBusinessRows.Record();record.setRecordVersion(3L);record.setArrivalsJson("[]");
record.setPhotosJson(codec.write(List.of(new Photo(PhotoSlot.FUEL_START,"FUEL","77","fuel.png","image/png","9","2026-10-21T00:00:00Z","2026-10-21T00:00:00Z"))));
var projection=new OwnedBusinessProjection(codec);var source=OwnedBusinessFixtures.source();source.setPooledCargo(true);source.setStage("DELIVERED");
var row=projection.row(source,record,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor(),OwnedBusinessScope.OUTSOURCED);
assertEquals(1,row.photoSummary().fuelCount());assertEquals("3",row.recordVersion());assertEquals(8L,source.getRecordVersion());assertEquals("DELIVERED",source.getStage());
assertEquals("77",projection.photos(record).get(0).fileId());
var queries=mock(OwnedBusinessQueryService.class);
when(queries.detail(any(),eq(source.getDispatchNo()),eq(OwnedBusinessScope.OUTSOURCED)))
.thenReturn(new Detail(row,null,List.of(),projection.photos(record)));
var outsourced=new OutsourcedBusinessService(queries,mock(OwnedBusinessRecordService.class),mock(OwnedBusinessPhotoService.class),projection,codec);
assertTrue(outsourced.detail(OwnedBusinessFixtures.actor(),source.getDispatchNo()).photos().isEmpty());
}
/** 新入口读写权限独立;服务层同样拒绝仅查看账号和车队权限绕入。 */
@Test void controllerAndPhotoServiceKeepDispatchWriteBoundary() throws Exception {
assertEquals("hasAuthority('platform:overseas-warehouse:dispatch:list')",Arrays.stream(DispatchVehicleDocumentController.class.getMethods())
.filter(m->m.getName().equals("get")).findFirst().orElseThrow().getAnnotation(PreAuthorize.class).value());
for(String name:List.of("upload","bind","recover"))assertEquals("hasAuthority('platform:overseas-warehouse:dispatch:edit')",Arrays.stream(DispatchVehicleDocumentController.class.getMethods())
.filter(m->m.getName().equals(name)).findFirst().orElseThrow().getAnnotation(PreAuthorize.class).value());
var queries=mock(OwnedBusinessQueryService.class);var repo=mock(OwnedBusinessRepository.class);var files=mock(OwnedBusinessFileGateway.class);var codec=OwnedBusinessFixtures.codec();
var service=new OwnedBusinessPhotoService(queries,mock(OwnedBusinessRecordService.class),repo,mock(OwnedBusinessCommandService.class),new OwnedBusinessProjection(codec),codec,files);
for(var actor:List.of(new Actor("read","只读",Set.of(OwnedBusinessScope.DISPATCH.listPermission())),OwnedBusinessFixtures.actor()))
assertThrows(ResponseStatusException.class,()->service.upload(actor,"PC-1",PhotoSlot.FUEL_START,"upload-original",null,OwnedBusinessScope.DISPATCH));
verifyNoInteractions(files,repo,queries);
}
}
+84 -20
View File
@@ -157,6 +157,10 @@ $adminIndex = Read-RequiredText $adminIndexPath
$adminAgents = $adminEntry + "`n" + $adminIndex
$adminOverseasAgentsPath = Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/AGENTS.md'
$adminOverseasAgents = Read-RequiredText $adminOverseasAgentsPath
# 页面细则归入既有业务正文;只读取对应子节,保留原有字段和状态断言。
$adminInboundPageRules = [regex]::Match($businessRules, '(?ms)^### Admin 入仓计划页面约束\r?\n(?<body>.*?)(?=^#{1,3} |\z)').Groups['body'].Value
$checked++
if (-not $adminInboundPageRules) { Add-Failure '缺少 Admin 入仓计划页面约束正文' }
Assert-Contains $adminAgents 'src/modules/overseas-warehouse/AGENTS.md' 'Admin 海外仓按需指引入口'
Assert-MarkdownTableUnique $adminOverseasAgentsPath '## 页面与业务快速索引' 0 'Admin 海外仓模块表'
$clientEntry = Read-RequiredText $clientAgentsPath
@@ -245,7 +249,8 @@ $lookupSamples = @(
$lookupCharacters = 0
foreach ($alias in @(
@{ Term = '转运打托'; Entry = 'apps/pda-overseas-uniapp/src/pages/warehouse-transfer/warehouse-transfer.vue' },
@{ Term = '转运驳回'; Entry = 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue' },
@{ Term = '转运通过'; Entry = 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue' },
@{ Term = '转运驳回'; Entry = 'apps/admin-web/src/modules/overseas-warehouse/api/warehouseTransfers.ts' },
@{ Term = '退税资料'; Entry = 'apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue' },
@{ Term = '订单审核'; Entry = 'apps/admin-web/src/modules/unified-orders/' },
@{ Term = 'PDA 出库'; Entry = 'apps/pda-overseas-uniapp/src/pages/dispatch/dispatch.vue' },
@@ -291,7 +296,8 @@ foreach ($document in @(
@{ Name = '业务导航'; Path = $businessIndexPath; Text = $businessIndex; Limit = 12500 },
@{ Name = '文档总入口'; Path = (Join-Path $workspaceRoot 'docs/README.md'); Text = $docsIndex; Limit = 3500 },
@{ Name = '海外仓文档入口'; Path = $overseasDocEntryPath; Text = $overseasDocEntry; Limit = 2000 },
@{ Name = '财务文档入口'; Path = $financeDocEntryPath; Text = $financeDocEntry; Limit = 1800 }
@{ Name = '财务文档入口'; Path = $financeDocEntryPath; Text = $financeDocEntry; Limit = 1800 },
@{ Name = 'Admin 海外仓指引'; Path = $adminOverseasAgentsPath; Text = $adminOverseasAgents; Limit = 8000 }
)) {
Assert-MarkdownLinks $document.Path $document.Text $document.Name
$checked++
@@ -306,11 +312,21 @@ foreach ($document in @(
)) { Assert-MarkdownLinks $document.Path $document.Text $document.Name }
Assert-TableRowWidth $businessIndexPath '## 业务快速路由表' 260 '详细业务导航表'
$businessRouteSection = (($businessIndex -split '(?m)^## 业务快速路由表\r?$', 2)[1] -split '(?m)^## ', 2)[0]
foreach ($topic in [regex]::Matches($businessRules, '(?m)^## (?<name>.+?)\r?$')) {
$prefix = '| ' + $topic.Groups['name'].Value + ' |'
$hits = @($businessRouteSection -split '\r?\n' | Where-Object { $_.StartsWith($prefix) })
# 同一规则可供多个终端入口复用;校验实际正文链接,不要求路由名称复制正文标题。
$businessRuleSections = [regex]::Matches($businessRules, '(?ms)^## (?<name>[^\r\n]+)\r?\n(?<body>.*?)(?=^## |\z)')
foreach ($topic in $businessRuleSections) {
$name = $topic.Groups['name'].Value
$slug = [regex]::Replace($name.Trim().ToLowerInvariant(), '[^\p{L}\p{M}\p{N}_\s-]', '') -replace '\s+', '-'
$target = '业务实现边界.md#' + $slug
$hasRoute = $businessRouteSection.Contains('](' + $target + ')')
if (-not $hasRoute) {
# 已有正式正文时允许索引直接跳转,避免为覆盖率强加一层摘要。
$sourceLine = [regex]::Match($topic.Groups['body'].Value, '(?m)^范围依据:[^\r\n]+')
$sourceLink = [regex]::Match($sourceLine.Value, '\[[^\]]+\]\((?<target>[^)]+)\)')
$hasRoute = $sourceLink.Success -and $businessRouteSection.Contains('](' + $sourceLink.Groups['target'].Value + ')')
}
$checked++
if ($hits.Count -ne 1) { Add-Failure "业务正文必须有唯一导航入口:$($topic.Groups['name'].Value)" }
if (-not $hasRoute) { Add-Failure "业务正文或其正式依据缺少导航入口:$name" }
}
Assert-Contains $rootAgents '备选导航' '索引不是串行读取清单'
Assert-Contains $rootAgents '命中正文即停止扩展' '正文命中后的读取停止条件'
@@ -319,20 +335,68 @@ Assert-NotContains $financeDocEntry '钱包不做' '财务入口不混入历史
Assert-Contains $financeP1 '首批固定为 CNY' '资金账户币种规则迁入现行正文'
Assert-Contains $financeP1 '充值先归还已用授信或负余额' '资金账户充值规则迁入现行正文'
Assert-Contains $financeP1 '新账户默认授信为 `0.00`' '资金账户默认授信规则迁入现行正文'
# 入口存在不足以证明动作存在:核对弹框实际调用与 API 结论,避免将接口能力当成按钮。
$transferReviewUi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue')
$transferReviewApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/api/warehouseTransfers.ts')
$transferUiRow = @($adminOverseasAgents -split '\r?\n' | Where-Object { $_.StartsWith('| 仓储转运审核、审核通过 |') })
$transferApiRow = @($adminOverseasAgents -split '\r?\n' | Where-Object { $_.StartsWith('| 转运驳回、REJECT 接口能力 |') })
$checked++
if ($transferUiRow.Count -ne 1 -or $transferApiRow.Count -ne 1) {
Add-Failure '转运审核的界面入口与 API 能力必须分别定位'
} else {
Assert-Contains $transferUiRow[0] 'components/WarehouseTransferReviewDialog.vue' '转运通过界面入口'
Assert-Contains $transferUiRow[0] 'APPROVE' '转运通过界面结论'
Assert-Contains $transferApiRow[0] 'api/warehouseTransfers.ts' '转运驳回 API 入口'
Assert-Contains $transferApiRow[0] 'REJECT' '转运 API 驳回结论'
Assert-Matches $transferReviewUi 'reviewWarehouseTransfer\([^)]*,\s*[''"]APPROVE[''"](?:\s*,[^)]*)?\s*\)' '转运通过真实调用'
Assert-Matches $transferReviewApi 'decision\s*:\s*[''"]APPROVE[''"]\s*\|\s*[''"]REJECT[''"]' '转运 API 允许的结论'
if ($transferReviewUi -match 'reviewWarehouseTransfer\([^)]*,\s*[''"]REJECT[''"]') {
Assert-NotContains $transferUiRow[0] '无驳回按钮' '转运界面能力变化后更新指引'
Assert-Contains $transferUiRow[0] 'REJECT' '转运界面新增驳回后更新指引'
} else {
Assert-Contains $transferUiRow[0] '无驳回按钮' '转运界面不能虚构驳回动作'
Assert-NotContains $transferUiRow[0] 'REJECT' '转运界面与 API 能力分开'
}
}
Assert-Contains $adminOverseasAgents 'baiyue-firstleg/print/AGENTS.md' '托盘模板指向 Print 权威入口'
Assert-NotContains $adminOverseasAgents 'INBOUND_MARK_V1' 'Admin 不复制箱唛模板为托盘模板'
$adminOverseasVerification = [regex]::Match($adminOverseasAgents, '(?ms)^## 验证\r?\n(?<body>.*?)(?=^## |\z)').Groups['body'].Value
$checked++
if (-not $adminOverseasVerification -or ($adminOverseasVerification -replace '\r', '').Length -gt 800) {
Add-Failure 'Admin 海外仓验证节须存在且不超过800字符,业务正文应归回按需条款'
}
$docLookupCharacters = 0
$walletRuleTarget = '财务管理/财务对账结算P1闭环.md#客户资金账户'
$docLookupSamples = @(
@{ Key = '客户头程订单批量导入'; Text = $businessIndex; Entry = 'apps/client-web/src/modules/first-leg/AGENTS.md' },
@{ Key = '客户资金账户、充值、授信与业务扣费'; Text = $businessIndex; Entry = 'apps/client-web/AGENTS.md' },
@{ Key = '客户账单、客户对账单、账单确认'; Text = $businessIndex; Entry = 'apps/client-web/src/modules/finance/views/FinanceBillsView.vue' },
@{ Key = '客户资金账户、充值、授信、冻结与业务扣费'; Text = $financeDocEntry; Entry = '财务对账结算P1闭环.md#客户资金账户' },
@{ Key = '海外排车、出库与派送改造'; Text = $overseasDocEntry; Entry = '排车列表统一作业改造契约.md' },
@{ Key = '本土卡车与散板表单'; Text = $overseasDocEntry; Entry = '业务规则.md#本土散板简化下单' }
@{ Key = '客户头程订单批量导入'; Text = $businessIndex; Column = 1; Entry = 'apps/client-web/src/modules/first-leg/AGENTS.md' },
@{ Key = '仓储转运审核、转运通过'; Text = $businessIndex; Column = 2; Entry = 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue' },
@{ Key = '转运驳回、REJECT'; Text = $businessIndex; Column = 2; Entry = 'apps/admin-web/src/modules/overseas-warehouse/api/warehouseTransfers.ts' },
@{ Key = '平台资金账户、充值审核、手工充扣与授信'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/finance/AGENTS.md'; Rules = $walletRuleTarget },
@{ Key = '客户资金账户、余额、授信与充值申请'; Text = $businessIndex; Column = 1; Entry = 'apps/client-web/src/modules/finance/AGENTS.md'; Rules = $walletRuleTarget },
@{ Key = '服务端资金账户、授信、冻结、结算与业务扣费'; Text = $businessIndex; Column = 1; Entry = 'baiyue-firstleg/finance/AGENTS.md'; Rules = $walletRuleTarget },
@{ Key = '海外仓库主档、名称、地址与能力'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/warehouses/AGENTS.md'; Rules = '业务实现边界.md#海外仓库与通用资料' },
@{ Key = '海外仓库主档、名称、地址与能力'; Text = $businessIndex; Column = 1; Entry = 'baiyue-firstleg/warehouse/AGENTS.md'; Rules = '业务实现边界.md#海外仓库与通用资料' },
@{ Key = '海外库位、托盘与资源锁'; Text = $businessIndex; Column = 1; Entry = 'baiyue-firstleg/warehouse-overseas-service/AGENTS.md'; Rules = '业务实现边界.md#海外仓库与通用资料' },
@{ Key = '客户统计与运营指标'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/customers/AGENTS.md' },
@{ Key = '平台日志、操作日志、审计日志与操作人缺失'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/audit/AGENTS.md' },
@{ Key = '客户账单、客户对账单、账单确认'; Text = $businessIndex; Column = 2; Entry = 'apps/client-web/src/modules/finance/views/FinanceBillsView.vue' },
@{ Key = '客户资金账户、充值、授信、冻结与业务扣费'; Text = $financeDocEntry; Column = 1; Entry = '财务对账结算P1闭环.md#客户资金账户' },
@{ Key = '海外排车、出库与派送改造'; Text = $overseasDocEntry; Column = 1; Entry = '排车列表统一作业改造契约.md' },
@{ Key = '本土卡车与散板表单'; Text = $overseasDocEntry; Column = 1; Entry = '业务规则.md#本土散板简化下单' }
)
foreach ($sample in $docLookupSamples) {
$hits = @($sample.Text -split '\r?\n' | Where-Object { $_.StartsWith('| ' + $sample.Key + ' |') })
$checked++
if ($hits.Count -ne 1) { Add-Failure "文档路由样本必须唯一命中:$($sample.Key)"; continue }
Assert-Contains $hits[0] $sample.Entry "文档路由样本 $($sample.Key)"
# 代码入口与规则正文分别核对,不能靠其他列碰巧包含目标路径通过。
$cells = @($hits[0].Trim('|').Split('|') | ForEach-Object { $_.Trim() })
if ($sample.Column -ge $cells.Count) { Add-Failure "文档路由样本缺少目标列:$($sample.Key)"; continue }
Assert-Contains $cells[$sample.Column] $sample.Entry "文档路由样本 $($sample.Key)"
if ($sample.Rules) {
if ($cells.Count -lt 3) { Add-Failure "文档路由样本缺少规则正文:$($sample.Key)"; continue }
Assert-Contains $cells[2] $sample.Rules "文档正文样本 $($sample.Key)"
}
$docLookupCharacters += $hits[0].Length
}
Write-Host "文档路由样本:$($docLookupSamples.Count) 项,命中行共 $docLookupCharacters 字符(不含上层指引或按需正文,不代表端到端耗时)。"
@@ -698,7 +762,7 @@ Assert-Contains $lastlegOrderCommandService '必须提交完整8行人工时间
Assert-Contains $lastlegOrderCommandService '尾程订单已进入出库流程,不能新增暂扣' '尾程暂扣出库门禁实现'
Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold' 'Admin 尾程暂扣入口'
Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/manual-nodes' 'Admin 尾程人工节点入口'
Assert-Contains $adminOverseasAgents '入仓码只读展示并提交订单冻结值' 'Admin 尾程计划冻结入仓码边界'
Assert-Contains $adminInboundPageRules '入仓码只读展示并提交订单冻结值' 'Admin 尾程计划冻结入仓码边界'
Assert-Contains $lastlegAgents '接柜仓编码/名称以该 ID 当前目录快照为准' 'Lastleg 稳定接柜仓身份边界'
Assert-Contains $lastlegOrderCommandService '同一幂等键不能用于不同请求' '尾程命令请求哈希重放边界'
Assert-Contains $loadingListsOpenApi '且真实集装箱号已经补录' '确认预装真实柜号门禁'
@@ -1044,16 +1108,16 @@ Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans
Assert-Contains $lastlegBusinessOpenApi 'snapshotVersion' 'Lastleg 整柜计划快照版本'
Assert-Contains $lastlegBusinessOpenApi 'snapshotHash' 'Lastleg 整柜计划快照哈希'
Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份'
Assert-Contains $adminOverseasAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口'
Assert-Contains $adminInboundPageRules '/orders/container-summaries' 'Admin 柜级摘要目标入口'
Assert-Contains $businessFacts '尾程订单控制与柜级入仓计划转换' '根柜级计划入口'
Assert-Contains $backendAgents '分页返回全部稳定来源柜' '后端全柜工作台范围'
Assert-Contains $adminOverseasAgents '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围'
Assert-Contains $adminInboundPageRules '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围'
Assert-Contains $lastlegAgents '分页返回全部稳定来源柜' 'Lastleg 全柜工作台范围'
Assert-Contains $lastlegBusinessOpenApi '返回全部稳定来源柜' 'Lastleg 全柜工作台 OpenAPI'
Assert-Contains $adminOverseasAgents 'ACTIVE + FBA' 'Admin FBA 权威仓候选'
Assert-Contains $adminOverseasAgents '/inbound-plans/convert-container' 'Admin 整柜转换目标入口'
Assert-Contains $adminOverseasAgents '/inbound-plans/container-preview' 'Admin 整柜预览目标入口'
Assert-Contains $adminOverseasAgents '同一 `snapshotVersion/snapshotHash` 原样提交' 'Admin 整柜快照确认边界'
Assert-Contains $adminInboundPageRules 'ACTIVE + FBA' 'Admin FBA 权威仓候选'
Assert-Contains $adminInboundPageRules '/inbound-plans/convert-container' 'Admin 整柜转换目标入口'
Assert-Contains $adminInboundPageRules '/inbound-plans/container-preview' 'Admin 整柜预览目标入口'
Assert-Contains $adminInboundPageRules '同一 `snapshotVersion/snapshotHash` 原样提交' 'Admin 整柜快照确认边界'
Assert-Contains $adminLastMileApi '/orders/container-summaries' 'Admin 柜级摘要真实调用'
Assert-Contains $adminLastMileApi '/platform/v1/last-leg/inbound-plans/container-preview' 'Admin 柜级预览真实调用'
Assert-NotContains $adminLastMileApi '/orders/pending-containers' 'Admin 旧柜级摘要调用'