From 4a0e86bd428bee8b162be2686d9b4de9ced023e1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 16:58:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DispatchLoadingDepartureService.java | 28 +- .../DispatchLoadingDocumentController.java | 60 ++ .../PdaDispatchLoadingDocumentController.java | 60 ++ .../DispatchLoadingDocumentService.java | 179 +++++ .../dispatch-vehicle-documents-v1.yaml | 621 +++++++----------- .../DispatchVehicleDocumentTest.java | 76 +++ scripts/agents-index-contract.ps1 | 47 +- 7 files changed, 667 insertions(+), 404 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/DispatchLoadingDocumentController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/PdaDispatchLoadingDocumentController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingDocumentService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/DispatchVehicleDocumentTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java index 4d83573a7..0db7c8884 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java @@ -9,7 +9,7 @@ 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; @@ -24,31 +24,31 @@ public class DispatchLoadingDepartureService { this.workflow=workflow;this.delivery=delivery;this.departure=departure;this.commands=commands;this.hashes=hashes; } - /** 原键恢复先于阶段校验;任一步失败均回滚装载、子单、费用、发车与全部账本。 */ + /** 两端装车完成只保存真实实载,原请求精确恢复装车后结果。 */ @Transactional(isolation=Isolation.READ_COMMITTED) public CommandResult complete(Actor actor,String number,DispatchCommands.LoadingComplete request) { workflow.requireLoadingPermission(actor); + return workflow.loadingComplete(actor,number,request); + } + + /** 仅供装车资料齐全的保存事务调用;派送准备和发车失败回滚最后一张绑定。 */ + @Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED) + public CommandResult departAfterDocuments(Actor actor,String number,String key) { 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(); + if(context.stage()!=Stage.AFTER_LOADING || context.status()!=Status.ACTIVE) + throw new BizException(ResultCode.CONFLICT,"当前阶段不能按装车资料推进派送"); // 先取得本车单据锁再申请真实来源锁,后续所有步骤保持同一个整车父锁。 delivery.departureFacts(context,true); - var loaded=workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(), - stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.measurements(),request.remark())); - context=workflow.preview(actor,number); - String arrangementKey=stepKey(number,request.idempotencyKey(),"ARRANGE"); + 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; } /** 固定长度内部键绑定原请求和步骤,不向客户端暴露中间命令作为恢复入口。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/DispatchLoadingDocumentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/DispatchLoadingDocumentController.java new file mode 100644 index 000000000..8e86f384f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/DispatchLoadingDocumentController.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.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.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.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 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 upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot 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 bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @PathVariable PhotoSlot 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 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 content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot 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()); + } +} + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/PdaDispatchLoadingDocumentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/PdaDispatchLoadingDocumentController.java new file mode 100644 index 000000000..9d214c073 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/PdaDispatchLoadingDocumentController.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.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.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.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 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 upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot 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 bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @PathVariable PhotoSlot 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 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 content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot 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()); + } +} + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingDocumentService.java new file mode 100644 index 000000000..7c20036b1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingDocumentService.java @@ -0,0 +1,179 @@ +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.dispatch.port.DispatchContracts.Actor; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Context; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.Stage; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; +import com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService; +import com.baiyue.lastleg.fleet.ownedbusiness.gateway.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +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.io.IOException; +import java.time.Instant; +import java.util.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; + +/** Admin与绑定仓PDA共用原整车五框照片;不复制派送单或文件引用。 */ +@Service +public class DispatchLoadingDocumentService { + public static final List SLOTS=List.of(PhotoSlot.LOADING_SCENE,PhotoSlot.LOADING_LIST, + PhotoSlot.PRE_DEPARTURE_CHECK,PhotoSlot.DRIVER_LICENSE,PhotoSlot.RECEIPT); + private final DispatchAggregateService workflow; + private final DispatchLoadingDepartureService departure; + private final OwnedBusinessRepository repository; + private final OwnedBusinessCommandService commands; + private final OwnedBusinessProjection projection; + private final OwnedBusinessSupport codec; + private final OwnedBusinessFileGateway files; + /** 复用现有附加表、文件归属校验和上传意图账本。 */ + public DispatchLoadingDocumentService(DispatchAggregateService workflow,DispatchLoadingDepartureService departure, + OwnedBusinessRepository repository,OwnedBusinessCommandService commands,OwnedBusinessProjection projection, + OwnedBusinessSupport codec,OwnedBusinessFileGateway files) { + this.workflow=workflow;this.departure=departure;this.repository=repository;this.commands=commands; + this.projection=projection;this.codec=codec;this.files=files; + } + public record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion, + String stage,boolean editable,boolean complete,List photos) {} + public record Binding(PhotoSlot slot,BindPhoto request) {} + public record Download(Photo photo,byte[] content) {} + + /** 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,repository.record(context.dispatchId()),canWrite(actor)); + } + + /** 先落稳定上传意图,再调用文件服务,未知结果不能换键重传。 */ + public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart) { + require(actor,true);slot(slot);codec.key(key);var context=context(actor,number,false); + 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(slot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes)); + var reservation=commands.reserveUpload(context.dispatchId(),operator(actor),key,identity); + if(!reservation.created())return uploaded(reservation.command()); + try { + var result=photo(slot,files.upload(context.dispatchId(),slot,actor.operatorId(),payload)); + commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return result; + } catch(RuntimeException failure) { + try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,"DISPATCH_LOADING_UPLOAD_UNKNOWN");} + catch(RuntimeException recording){failure.addSuppressed(recording);} + throw conflict("照片上传结果未知,请保留原请求键核对"); + } + } + + /** 原上传恢复只允许原操作者和本车装车槽位。 */ + public Photo recover(Actor actor,String number,String key) { + require(actor,true);codec.key(key);var context=context(actor,number,false); + var command=commands.existing(context.dispatchId(),key,operator(actor)); + if(command==null)throw new BizException(ResultCode.NOT_FOUND,"未找到本车原上传请求"); + return uploaded(command); + } + + /** 锁顺序为车次、排车、附加行;第五张绑定与派送发车同事务成功或回滚。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public Documents bind(Actor actor,String number,PhotoSlot slot,BindPhoto request) { + require(actor,true);slot(slot);if(request==null)throw bad("照片绑定请求缺失"); + codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion()),fileId=codec.version(request.fileId()); + if(fileId<=0)throw bad("文件编号无效"); + var context=context(actor,number,true);var operator=operator(actor);var identity=new Binding(slot,request); + var old=commands.existing(context.dispatchId(),request.idempotencyKey(),operator); + if(old!=null) { + commands.same(old,"BIND_LOADING_DOCUMENT",version,identity); + if(!"SUCCEEDED".equals(old.getStatus()))throw conflict("原资料保存结果尚未确认"); + return codec.read(old.getResultJson(),Documents.class); + } + // 文件必须属于本排车与指定槽位,不能拿其他车或签收资料充数。 + var verified=photo(slot,files.metadata(context.dispatchId(),slot,fileId)); + var seed=new OwnedBusinessRows.Record();seed.setId(IdWorker.getId());seed.setDispatchId(context.dispatchId()); + seed.setCreatedBy(actor.operatorId());seed.setUpdatedBy(actor.operatorId());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.class); + for(var value:projection.photos(row))photos.put(value.slot(),value); + photos.put(slot,new Photo(slot,slot.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(actor.operatorId());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(context.dispatchId()); + history.setRecordVersion(row.getRecordVersion());history.setAction("BIND_LOADING_DOCUMENT");history.setOperatorId(actor.operatorId()); + history.setReason("保存装车照片 "+(SLOTS.indexOf(slot)+1));history.setOccurredAt(row.getUpdatedAt()); + history.setSnapshotJson(codec.write(new Snapshot(projection.operations(row),projection.photos(row),actor.operatorId())));repository.history(history); + if(context.stage()==Stage.AFTER_LOADING && complete(List.copyOf(photos.values()))) { + departure.departAfterDocuments(actor,number,request.idempotencyKey()); + context=workflow.preview(actor,number); + } + var result=documents(context,row,true); + commands.success(context.dispatchId(),operator,request.idempotencyKey(),"BIND_LOADING_DOCUMENT",version,identity,result); + return result; + } + + /** 预览必须引用当前本车已绑定文件,下载不会暴露未保存上传。 */ + @Transactional(readOnly=true) + public Download content(Actor actor,String number,PhotoSlot slot,long fileId) { + require(actor,false);slot(slot);var context=context(actor,number,false); + var photo=projection.photos(repository.record(context.dispatchId())).stream() + .filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst() + .orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车未绑定该装车照片")); + return new Download(photo,files.content(context.dispatchId(),slot,fileId)); + } + + /** 阶段与真实装载证据均须存在,PDA仓隔离由主流程统一复核。 */ + 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.AFTER_LOADING,Stage.DELIVERING,Stage.DELIVERED).contains(value.stage()) + || value.loadingCompletedAt()==null || value.loadingCompletionHash()==null)throw conflict("装车完成后才能管理装车资料"); + return value; + } + /** 返回当前共享附加版本,五个固定框各有一份已保存图片才算齐全。 */ + private Documents documents(Context context,OwnedBusinessRows.Record row,boolean editable) { + var photos=projection.photos(row).stream().filter(p->SLOTS.contains(p.slot())).toList(); + return new Documents(context.dispatchNo(),row==null?"0":row.getRecordVersion().toString(),Long.toString(context.recordVersion()), + context.stage().name(),editable,complete(photos),photos); + } + /** 不按总张数判断,重复槽位和空文件引用不能满足五框。 */ + static boolean complete(List photos) { + return SLOTS.stream().allMatch(slot->photos.stream().anyMatch(p->p.slot()==slot && p.fileId()!=null && !p.fileId().isBlank())); + } + /** 平台读写权限分离,PDA沿既有仓作业身份授权。 */ + private void require(Actor actor,boolean write) { + if(actor==null || actor.operatorId()==null || !allowed(actor,write))throw new BizException(ResultCode.FORBIDDEN,"无装车资料操作权限"); + } + /** 上传按钮只反映当前主体的实际写权限。 */ + private boolean canWrite(Actor actor){return allowed(actor,true);} + /** 权限直接读取当前会话,不给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")); + } + /** 账本只消费可信操作者,权限由本服务独立校验。 */ + private OwnedBusinessModels.Actor operator(Actor actor){return new OwnedBusinessModels.Actor(actor.operatorId(),actor.operatorId(),Set.of());} + /** 油耗照片不能替代装车五框。 */ + private void slot(PhotoSlot slot){if(!SLOTS.contains(slot))throw bad("请选择有效的装车照片框");} + /** 恢复回执仍须属于上传动作及装车五框。 */ + private Photo uploaded(OwnedBusinessRows.Command command) { + if(!"UPLOAD_PHOTO".equals(command.getAction()) || !"SUCCEEDED".equals(command.getStatus()) || command.getResultJson()==null) + throw conflict("原上传结果尚未确认,请保留原请求"); + var result=codec.read(command.getResultJson(),Photo.class);slot(result.slot());return result; + } + /** 上传回执不标记已绑定,实际保存时间只在绑定事务填写。 */ + private Photo photo(PhotoSlot slot,OwnedBusinessFileGateway.Evidence value) { + return new Photo(slot,slot.category(),Long.toString(value.fileId()),value.fileName(),value.contentType(),Long.toString(value.sizeBytes()),value.uploadedAt(),null); + } +} diff --git a/lastleg-service/src/main/resources/openapi/dispatch-vehicle-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/dispatch-vehicle-documents-v1.yaml index 1bc04a1e7..e6fe3e2d6 100644 --- a/lastleg-service/src/main/resources/openapi/dispatch-vehicle-documents-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/dispatch-vehicle-documents-v1.yaml @@ -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 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/DispatchVehicleDocumentTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/DispatchVehicleDocumentTest.java new file mode 100644 index 000000000..b06c3ddfb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/DispatchVehicleDocumentTest.java @@ -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); + } +} diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index ae20134ad..3aee9acaa 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -306,11 +306,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)^## (?.+?)\r?$')) { - $prefix = '| ' + $topic.Groups['name'].Value + ' |' - $hits = @($businessRouteSection -split '\r?\n' | Where-Object { $_.StartsWith($prefix) }) +# 同一规则可供多个终端入口复用;校验实际正文链接,不要求路由名称复制正文标题。 +$businessRuleSections = [regex]::Matches($businessRules, '(?ms)^## (?[^\r\n]+)\r?\n(?.*?)(?=^## |\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, '\[[^\]]+\]\((?[^)]+)\)') + $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 '命中正文即停止扩展' '正文命中后的读取停止条件' @@ -320,19 +330,34 @@ Assert-Contains $financeP1 '首批固定为 CNY' '资金账户币种规则迁入 Assert-Contains $financeP1 '充值先归还已用授信或负余额' '资金账户充值规则迁入现行正文' Assert-Contains $financeP1 '新账户默认授信为 `0.00`' '资金账户默认授信规则迁入现行正文' $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 = 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 字符(不含上层指引或按需正文,不代表端到端耗时)。"