From a5befc9bad7b10a9b63481277d231dad014145ce Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 26 Oct 2026 19:21:11 +0800 Subject: [PATCH] fix(dispatch): invalidate resource evidence and bind uploads to review rounds --- .../DispatchLoadingDocumentController.java | 4 +- .../PdaDispatchLoadingDocumentController.java | 4 +- .../DispatchLoadingDocumentService.java | 11 ++-- .../service/DispatchMasterPhotoService.java | 12 ++++ .../port/DispatchLoadingPhotoPort.java | 10 ++-- .../service/DispatchLoadingPhotoAdapter.java | 29 +++++++--- .../dispatch-loading-documents-v1.yaml | 22 +++++++- ...spatchLoadingDeparturePersistenceTest.java | 55 ++++++++++++++----- .../DispatchMasterPhotoServiceTest.java | 11 +++- ...ross-customer-acceptance-contract.test.mjs | 2 +- ...rnal-dispatch-acceptance-contract.test.mjs | 2 +- ...rn-redispatch-acceptance-contract.test.mjs | 2 +- .../fleet-trip-fulfillment-contract.test.mjs | 9 +-- .../fleet-trip-fulfillment.mjs | 8 ++- scripts/dispatch-loading-flow-test-env.mjs | 8 ++- 15 files changed, 138 insertions(+), 51 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchLoadingDocumentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchLoadingDocumentController.java index 3adc9d1aa..8422ec02e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchLoadingDocumentController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchLoadingDocumentController.java @@ -32,8 +32,8 @@ public class DispatchLoadingDocumentController { @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 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)); + @RequestParam @Positive long reviewRound,@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) { + return ApiResponse.success(service.upload(DispatchActorFactory.platform(user),dispatchNo,slot,reviewRound,idempotencyKey,file)); } /** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */ @PutMapping("/{slot}") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/PdaDispatchLoadingDocumentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/PdaDispatchLoadingDocumentController.java index 229ea7140..efcae4d71 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/PdaDispatchLoadingDocumentController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/PdaDispatchLoadingDocumentController.java @@ -32,8 +32,8 @@ public class PdaDispatchLoadingDocumentController { @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 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)); + @RequestParam @Positive long reviewRound,@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) { + return ApiResponse.success(service.upload(DispatchActorFactory.pda(user),dispatchNo,slot,reviewRound,idempotencyKey,file)); } /** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */ @PutMapping("/{slot}") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDocumentService.java index e49905832..8feab3034 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDocumentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDocumentService.java @@ -30,8 +30,11 @@ public class DispatchLoadingDocumentService { 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 upload(Actor actor,String number,Slot slot,long reviewRound,String key,MultipartFile file) { + require(actor,true);var context=context(actor,number,false); + if(reviewRound<=0 || reviewRound!=context.reviews().loadingReview().reviewRound()) + throw new BizException(ResultCode.CONFLICT,"审核轮次已变化,请刷新后重新选择资料"); + return photos.upload(context.dispatchId(),actor.operatorId(),slot,reviewRound,key,file); } /** 原操作者仍须有权限且满足本车绑定仓范围,不能跨账号恢复。 */ public Photo recover(Actor actor,String number,String key) { @@ -42,7 +45,7 @@ public class DispatchLoadingDocumentService { 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); + var state=photos.bind(context.dispatchId(),actor.operatorId(),slot,context.reviews().loadingReview().reviewRound(),request); if(context.stage()==Stage.AFTER_LOADING && complete(state.photos())) { departure.departAfterDocuments(actor,number,request.idempotencyKey());context=workflow.preview(actor,number); } @@ -64,7 +67,7 @@ public class DispatchLoadingDocumentService { } /** 对外同时返回资料版本与当前排车阶段,二者不互相替代。 */ 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()); + return new Documents(context.dispatchNo(),state.recordVersion(),Long.toString(context.recordVersion()),Long.toString(context.reviews().loadingReview().reviewRound()),context.stage().name(),editable,complete(state.photos()),state.photos()); } /** 按五个固定框核齐,不能用总数或重复槽位代替。 */ static boolean complete(List photos) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoService.java index 9037a60ec..977277655 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoService.java @@ -32,8 +32,20 @@ public class DispatchMasterPhotoService { var slots=java.util.EnumSet.noneOf(Slot.class); if(!Objects.equals(vehicleSource(before.vehicle()),vehicleSource(after.vehicle())))slots.add(Slot.LOADING_LIST); if(!Objects.equals(driverSource(before.driver()),driverSource(after.driver())))slots.add(Slot.LOADING_SCENE); + if(!Objects.equals(vehicleIdentity(before.vehicle()),vehicleIdentity(after.vehicle()))) { + slots.add(Slot.PRE_DEPARTURE_CHECK);slots.add(Slot.DRIVER_LICENSE); + } + if(!Objects.equals(driverIdentity(before.driver()),driverIdentity(after.driver())))slots.add(Slot.RECEIPT); if(!slots.isEmpty())loadingPhotos.invalidateResourcePhotos(dispatchId,operator,slots); } + /** 换车必须重新记录装车和封车现场;同车补图不影响现场凭证。 */ + private Object vehicleIdentity(VehicleSnapshot value) { + return value==null?null:java.util.Arrays.asList(value.mode(),value.vehicleId(),value.plate()); + } + /** 签字对应实际司机身份,不能在换司机后沿用。 */ + private Object driverIdentity(DriverSnapshot value) { + return value==null?null:java.util.Arrays.asList(value.mode(),value.driverId(),value.name(),value.evidence()==null?null:value.evidence().licenseNo()); + } /** 只比较车辆身份及照片,不因容量或运营状态变更废弃现场资料。 */ private Object vehicleSource(VehicleSnapshot value) { return value==null?null:java.util.Arrays.asList(value.mode(),value.vehicleId(),value.plate(),sourceFiles(value.evidenceFileIds())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/DispatchLoadingPhotoPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/DispatchLoadingPhotoPort.java index ab70715e2..4fdd57c3c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/DispatchLoadingPhotoPort.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/DispatchLoadingPhotoPort.java @@ -9,12 +9,12 @@ 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, - @Pattern(regexp="[1-9][0-9]*") String replaceFileId) { + @Pattern(regexp="[1-9][0-9]*") String replaceFileId,@NotBlank @Size(min=8,max=128) String uploadKey) { /** 未指定替换目标时追加照片,每类最多五张。 */ - public BindPhoto(String recordVersion,String idempotencyKey,String fileId) { this(recordVersion,idempotencyKey,fileId,null); } + public BindPhoto(String recordVersion,String idempotencyKey,String fileId,String uploadKey) { this(recordVersion,idempotencyKey,fileId,null,uploadKey); } } record State(String recordVersion,List photos) {} - record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String stage,boolean editable,boolean complete,List photos) {} + record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String reviewRound,String stage,boolean editable,boolean complete,List photos) {} record Download(Photo photo,byte[] content) {} /** 只读取原整车附加资料版本与五框,不初始化记录。 */ State read(long dispatchId); @@ -23,13 +23,13 @@ public interface DispatchLoadingPhotoPort { /** 已持排车父锁;资源变化使对应当前资料失效,旧文件与版本历史保留。 */ void invalidateResourcePhotos(long dispatchId,String operator,java.util.Set slots); /** 上传保存原请求意图;不提前计入已保存照片。 */ - Photo upload(long dispatchId,String operator,Slot slot,String key,MultipartFile file); + Photo upload(long dispatchId,String operator,Slot slot,long reviewRound,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); + State bind(long dispatchId,String operator,Slot slot,long reviewRound,BindPhoto request); /** 与绑定及自动发车共同提交完整原结果。 */ void recordBind(long dispatchId,String operator,Slot slot,BindPhoto request,Documents result); /** 文件只可从本车当前已绑定槽位下载。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java index 7f9320fb3..4318a76ae 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java @@ -34,7 +34,6 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort { /** 与资源修改同事务失效旧凭证;不变类别及历史保留,旧资料版本不得继续保存。 */ @Override @Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED) public void invalidateResourcePhotos(long id,String operator,Set slots) { - if(!Set.of(Slot.LOADING_SCENE,Slot.LOADING_LIST).containsAll(slots))throw bad("仅可失效车辆或司机资料"); var row=repository.record(id); if(row==null || slots.isEmpty())return; row=repository.lock(row); @@ -59,7 +58,7 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort { try { var payload=new OwnedBusinessFileGateway.Upload(source.getOriginalFilename(),source.getContentType(),source.getBytes()); var uploaded=files.upload(id,slot(slot),operator,payload); - state=bind(id,operator,slot,new BindPhoto(state.recordVersion(),UUID.randomUUID().toString(),Long.toString(uploaded.fileId()))); + state=savePhoto(id,operator,slot,codec.version(state.recordVersion()),uploaded.fileId(),null); } catch(IOException failure) { throw bad("读取本趟资料失败,未完成带入"); } } } @@ -70,13 +69,14 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort { .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) { + @Override public Photo upload(long id,String operator,Slot slot,long reviewRound,String key,MultipartFile multipart) { codec.key(key);var originalSlot=slot(slot); + if(reviewRound<=0)throw bad("审核轮次无效"); 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(originalSlot,payload); - var identity=new OwnedBusinessPhotoService.UploadIdentity(originalSlot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes)); + var identity=new LoadingUploadIdentity(reviewRound,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 { @@ -102,15 +102,27 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort { } /** 父排车锁由调用者持有;附加版本竞争与最后一张发车失败沿外层事务回滚。 */ @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()))); + public State bind(long id,String operator,Slot slot,long reviewRound,BindPhoto request) { + validate(slot,request); + var command=commands.existing(id,request.uploadKey(),operator(operator)); + if(command==null)throw conflict("未找到本次照片的原上传记录,请重新上传"); + var uploaded=uploaded(command); + var identity=codec.read(command.getRequestJson(),LoadingUploadIdentity.class); + if(identity.reviewRound()!=reviewRound)throw conflict("照片所属审核轮次已变化,请刷新后重新上传"); + if(uploaded.slot()!=slot || !Objects.equals(uploaded.fileId(),request.fileId()))throw bad("照片与原上传记录不一致"); + return savePhoto(id,operator,slot,codec.version(request.recordVersion()),codec.version(request.fileId()),request.replaceFileId()); + } + public record LoadingUploadIdentity(long reviewRound,OwnedBusinessPhotoService.UploadIdentity file) {} + /** 自动复制与已核验上传共用保存逻辑;外部绑定必须先通过上传轮次校验。 */ + private State savePhoto(long id,String operator,Slot slot,long version,long fileId,String replaceFileId) { + var originalSlot=slot(slot); + var verified=photo(originalSlot,files.metadata(id,originalSlot,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 saved=new OwnedBusinessModels.Photo(originalSlot,originalSlot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()); - var photos=OwnedBusinessPhotoBindings.bind(projection.photos(row),saved,request.replaceFileId()); + var photos=OwnedBusinessPhotoBindings.bind(projection.photos(row),saved,replaceFileId); row.setPhotosJson(codec.write(photos));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()); @@ -135,6 +147,7 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort { /** 版本0合法,文件编号必须为正且请求键不得缺失。 */ private void validate(Slot slot,BindPhoto request) { slot(slot);if(request==null)throw bad("照片绑定请求缺失");codec.key(request.idempotencyKey());codec.version(request.recordVersion()); + codec.key(request.uploadKey()); if(codec.version(request.fileId())<=0)throw bad("文件编号无效"); if(request.replaceFileId()!=null && codec.version(request.replaceFileId())<=0)throw bad("待替换文件编号无效"); } diff --git a/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml index 8205cab9c..f74c4aa61 100644 --- a/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: 排车装车资料(Admin/PDA共用) version: 1.0.0 - description: 理货审核通过时,将本趟已核验驾驶证和车辆照片复制到空的司机驾驶证、车牌照片类别(每类最多5份),来源未变的已有照片保持不变;保存车辆、司机或证照来源变更时,对应类别旧资料与资源保存同事务失效并保留历史,重审通过后带入新资料;PDF驾驶证保留原件并计为一份。GET只读,不对存量记录补写。LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位沿用原文件归属,按顺序对应司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;每类1至5张,允许追加或指定replaceFileId精确替换,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。 + description: 理货审核通过时,将本趟已核验驾驶证和车辆照片复制到空的司机驾驶证、车牌照片类别(每类最多5份),来源未变的已有照片保持不变;保存车辆、司机或证照来源变更时,对应类别旧资料与资源保存同事务失效并保留历史;换车另失效装车完成及封车照片,换司机另失效BOL签字,重审通过后只带入驾驶证与车牌资料;PDF驾驶证保留原件并计为一份。GET只读,不对存量记录补写。LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位沿用原文件归属,按顺序对应司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;每类1至5张,允许追加或指定replaceFileId精确替换,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。上传须携当前reviewRound并将轮次写入上传账本;绑定须携uploadKey核对原上传文件、分类、操作者及当前审核轮次,不允许用最新资料版本重绑旧轮次上传。 security: - bearerAuth: [] paths: @@ -80,11 +80,16 @@ paths: required: - file - idempotencyKey + - reviewRound properties: file: type: string format: binary description: 非空JPG/PNG,司机驾驶证(LOADING_SCENE)也支持PDF,最大10MiB + reviewRound: + type: string + pattern: "^[1-9][0-9]*$" + description: 选择照片时看到的审核轮次;不得在上传后改用新轮次。 idempotencyKey: type: string minLength: 8 @@ -120,11 +125,16 @@ paths: required: - file - idempotencyKey + - reviewRound properties: file: type: string format: binary description: 非空JPG/PNG,司机驾驶证(LOADING_SCENE)也支持PDF,最大10MiB + reviewRound: + type: string + pattern: "^[1-9][0-9]*$" + description: 选择照片时看到的审核轮次;不得在上传后改用新轮次。 idempotencyKey: type: string minLength: 8 @@ -359,6 +369,7 @@ components: - editable - photos - dispatchRecordVersion + - reviewRound - stage - complete properties: @@ -374,6 +385,9 @@ components: $ref: '#/components/schemas/Photo' dispatchRecordVersion: type: string + reviewRound: + type: string + pattern: "^[1-9][0-9]*$" stage: type: string enum: @@ -389,11 +403,17 @@ components: required: - recordVersion - idempotencyKey + - uploadKey - fileId properties: recordVersion: type: string pattern: ^(0|[1-9][0-9]*)$ + uploadKey: + type: string + minLength: 8 + maxLength: 128 + description: 本文件原上传请求的幂等键;与当前审核轮次及操作者一致。 idempotencyKey: type: string minLength: 8 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java index e05023b01..75ba5e687 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java @@ -30,11 +30,24 @@ import static org.mockito.ArgumentMatchers.*; /** 装车后五框门禁、保存发车共同事务及跨端竞争使用真实单据和版本。 */ class DispatchLoadingDeparturePersistenceTest { + /** 绑定必须引用本人的原上传文件及分类,不能用另一张图或另一个操作者的回执充数。 */ + @Test void bindingRequiresTheOriginalUploadFileSlotAndActor() throws Exception { + authorize();var f=new Fixture(); + var request=f.uploadedRequest(f.f.actor,Slot.LOADING_SCENE,"0","source-proof","2500",null); + var wrongFile=new BindPhoto("0","different-file","2501",request.uploadKey()); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,wrongFile))).hasMessageContaining("原上传记录不一致"); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_LIST,request))).hasMessageContaining("原上传记录不一致"); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(new Actor("picker",ActorMode.PDA,"9"),f.number,Slot.LOADING_SCENE,request))).hasMessageContaining("不属于当前操作账号"); + assertThat(f.documents.get(f.f.actor,f.number).photos()).isEmpty(); + assertThat(f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,request)).photos()).extracting(p->p.fileId()).containsExactly("2500"); + } /** 零扫码退回后换资源,旧凭证失效与资源保存原子提交;重审自动带入新资料供两端共用。 */ @Test void changedResourcesInvalidateOldDocumentsAndReapprovalImportsNewEvidence() throws Exception { authorize();var f=new Fixture(false);long id=f.f.repository.find(f.number).getId(); for(int i=0;i<5;i++)f.bind(i,f.f.actor); var old=f.photos.read(id);assertThat(DispatchLoadingDocumentService.complete(old.photos())).isTrue(); + var pending=f.uploadedRequest(f.f.actor,Slot.LOADING_SCENE,old.recordVersion(),"pending-old-round","2500",null); + clearInvocations(f.files); var master=(DispatchMasterPhotoService)org.springframework.test.util.ReflectionTestUtils.getField(f.f.service,"masterPhotos"); org.springframework.test.util.ReflectionTestUtils.setField(master,"loadingPhotos",f.photos); doCallRealMethod().when(master).populateLoading(anyLong(),anyString(),any()); @@ -53,7 +66,7 @@ class DispatchLoadingDeparturePersistenceTest { assertThat(f.f.repository.find(f.number).getResourcesJson()).isEqualTo(previousResources); var updated=f.f.tx.execute(s->f.f.service.update(f.f.actor,f.number,request)); var invalidated=f.photos.read(id);assertThat(invalidated.recordVersion()).isEqualTo("6"); - assertThat(invalidated.photos()).containsExactlyElementsOf(old.photos().subList(2,5)); + assertThat(invalidated.photos()).isEmpty(); assertThat(DispatchLoadingDocumentService.complete(invalidated.photos())).isFalse(); assertThat(f.f.tx.execute(s->f.f.service.update(f.f.actor,f.number,request)).replayed()).isTrue(); assertThat(f.photos.read(id)).isEqualTo(invalidated); @@ -63,11 +76,16 @@ class DispatchLoadingDeparturePersistenceTest { var reviewed=f.f.tx.execute(s->f.f.service.review(f.f.actor,f.number,false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch-changed",Decision.APPROVED,null,null))); f.f.tx.execute(s->f.f.service.review(f.f.actor,f.number,true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),"loading-changed",Decision.APPROVED,null,null))); var admin=f.documents.get(f.f.actor,f.number);var pda=f.documents.get(new Actor("picker",ActorMode.PDA,"9"),f.number); - assertThat(admin.photos()).isEqualTo(pda.photos()).hasSize(5);assertThat(admin.complete()).isTrue(); - assertThat(admin.photos().stream().map(p->p.fileId())).containsExactlyInAnyOrder("3001","3002","1002","1003","1004"); + assertThat(admin.photos()).isEqualTo(pda.photos()).hasSize(2);assertThat(admin.complete()).isFalse(); + assertThat(admin.photos().stream().map(p->p.fileId())).containsExactlyInAnyOrder("3001","3002"); verify(evidence).download(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,222L); verify(evidence).download(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,111L); - assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,new BindPhoto("5","stale-before-change","1000")))).hasMessageContaining("其他操作更新"); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,new BindPhoto("5","stale-before-change","1000","upload-photo-bind-0")))).hasMessageContaining("审核轮次已变化"); + var rebased=new BindPhoto(admin.recordVersion(),"rebased-old-upload",pending.fileId(),pending.uploadKey()); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,rebased))).hasMessageContaining("审核轮次已变化"); + var image=new org.springframework.mock.web.MockMultipartFile("file","old.png","image/png",new byte[]{(byte)137,80,78,71,13,10,26,10}); + assertThatThrownBy(()->f.documents.upload(f.f.actor,f.number,Slot.LOADING_SCENE,1,"stale-upload-request",image)).hasMessageContaining("审核轮次已变化"); + assertThat(f.photos.read(id).photos()).isEqualTo(admin.photos()); f.f.tx.execute(s->f.f.service.review(f.f.actor,f.number,true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),"loading-changed",Decision.APPROVED,null,null))); verify(f.files,times(2)).upload(anyLong(),any(),anyString(),any()); } @@ -105,7 +123,7 @@ class DispatchLoadingDeparturePersistenceTest { 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"))); + 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","upload-photo-bind-4"))); 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"); @@ -175,7 +193,7 @@ class DispatchLoadingDeparturePersistenceTest { 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("装车照片框"); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,null,new BindPhoto("0","wrong-slot","1000","upload-photo-bind-0")))).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("无装车资料操作权限"); @@ -190,7 +208,7 @@ class DispatchLoadingDeparturePersistenceTest { var results=new ArrayList>(); 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; + f.f.tx.execute(s->f.documents.bind(actor,f.number,Slot.RECEIPT,f.uploadedRequest(actor,Slot.RECEIPT,"4",key,"1004",null)));return true; }catch(com.baiyue.common.core.exception.BizException expected){return false;} finally {SecurityContextHolder.clearContext();} })); @@ -249,20 +267,20 @@ class DispatchLoadingDeparturePersistenceTest { @Test void multiplePhotosPerCategoryPreserveReferencesAndDepartureGate() throws Exception { authorize();var f=new Fixture(); for(int i=0;i<5;i++) { - var request=new BindPhoto(Integer.toString(i),"multi-photo-"+i,Long.toString(2000+i)); + var request=f.uploadedRequest(f.f.actor,Slot.LOADING_SCENE,Integer.toString(i),"multi-photo-"+i,Long.toString(2000+i),null); assertThat(f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,request)).complete()).isFalse(); } assertThat(f.documents.get(f.f.actor,f.number).photos()).hasSize(5); - assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,new BindPhoto("5","too-many-photo","3000")))).hasMessageContaining("最多5张"); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,f.uploadedRequest(f.f.actor,Slot.LOADING_SCENE,"5","too-many-photo","3000",null)))).hasMessageContaining("最多5张"); assertThat(f.documents.get(f.f.actor,f.number).recordVersion()).isEqualTo("5"); - var replace=new BindPhoto("5","replace-photo","3000","2002"); + var replace=f.uploadedRequest(f.f.actor,Slot.LOADING_SCENE,"5","replace-photo","3000","2002"); var result=f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,replace)); assertThat(result.photos()).extracting(p->p.fileId()).containsExactly("2000","2001","3000","2003","2004"); var replay=f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,replace)); assertThat(replay).isEqualTo(result); - assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_LIST,new BindPhoto("6","wrong-photo-slot","4000","2000")))).hasMessageContaining("待替换照片已变化"); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_LIST,f.uploadedRequest(f.f.actor,Slot.LOADING_LIST,"6","wrong-photo-slot","4000","2000")))).hasMessageContaining("待替换照片已变化"); for(int i=1;i<5;i++) { - var slot=DispatchLoadingDocumentService.SLOTS.get(i);var request=new BindPhoto(Integer.toString(5+i),"other-category-"+i,Long.toString(4000+i)); + var slot=DispatchLoadingDocumentService.SLOTS.get(i);var request=f.uploadedRequest(f.f.actor,slot,Integer.toString(5+i),"other-category-"+i,Long.toString(4000+i),null); f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,slot,request)); } assertThat(f.documents.get(f.f.actor,f.number).photos()).hasSize(9); @@ -327,13 +345,24 @@ class DispatchLoadingDeparturePersistenceTest { var records=new OwnedBusinessRepository(f.session.getMapper(OwnedBusinessMapper.class),mock(OwnedBusinessQueryMapper.class)); var codec=new OwnedBusinessSupport(f.json); when(files.metadata(anyLong(),any(),anyLong())).thenAnswer(call->new OwnedBusinessFileGateway.Evidence(call.getArgument(2),"loading.png","image/png",12,"2026-10-21T00:00:00Z")); + when(files.upload(anyLong(),any(),anyString(),any())).thenAnswer(call->{ + OwnedBusinessFileGateway.Upload payload=call.getArgument(3); + return new OwnedBusinessFileGateway.Evidence(Long.parseLong(payload.fileName().replace(".png","")),payload.fileName(),payload.contentType(),payload.content().length,"2026-10-26T00:00:00Z"); + }); 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)))); + return f.tx.execute(s->documents.bind(actor,number,DispatchLoadingDocumentService.SLOTS.get(index),uploadedRequest(actor,DispatchLoadingDocumentService.SLOTS.get(index),Integer.toString(index),"photo-bind-"+index,Long.toString(1000L+index),null))); + } + /** 使用真实上传账本记录来源轮次;仅隔离远程文件字节存储。 */ + BindPhoto uploadedRequest(Actor actor,Slot slot,String version,String key,String fileId,String replaceFileId) { + var file=new org.springframework.mock.web.MockMultipartFile("file",fileId+".png","image/png",new byte[]{(byte)137,80,78,71,13,10,26,10}); + String uploadKey="upload-"+key; + documents.upload(actor,number,slot,Long.parseLong(documents.get(actor,number).reviewRound()),uploadKey,file); + return new BindPhoto(version,key,fileId,replaceFileId,uploadKey); } /** 每次请求沿用最初真实测量和版本,便于验证竞争及原键恢复。 */ DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,"实装发车");} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoServiceTest.java index d7b4b0e65..f91baf14f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoServiceTest.java @@ -26,15 +26,20 @@ class DispatchMasterPhotoServiceTest { verifyNoInteractions(loading); var changedVehicle=new VehicleSnapshot(ResourceMode.MASTER,3L,"OTHER",null,"IN_SERVICE",null,List.of(103L)); service.invalidateLoading(9,"op",original,new Resources(changedVehicle,driver,null)); - verify(loading).invalidateResourcePhotos(9,"op",java.util.Set.of(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_LIST)); + verify(loading).invalidateResourcePhotos(9,"op",java.util.Set.of(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_LIST, + com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.PRE_DEPARTURE_CHECK,com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.DRIVER_LICENSE)); clearInvocations(loading); var renewedDriver=new DriverSnapshot(ResourceMode.MASTER,2L,"司机","电话",new DriverEvidence("LICENSE",null,List.of(9L),null,List.of(90L),9L)); service.invalidateLoading(9,"op",original,new Resources(vehicle,renewedDriver,null)); verify(loading).invalidateResourcePhotos(9,"op",java.util.Set.of(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_SCENE)); clearInvocations(loading); - service.invalidateLoading(9,"op",original,new Resources(null,null,null)); + var changedDriver=new DriverSnapshot(ResourceMode.MASTER,3L,"新司机","电话",new DriverEvidence("NEW-LICENSE",null,List.of(9L),null,List.of(90L),9L)); + service.invalidateLoading(9,"op",original,new Resources(vehicle,changedDriver,null)); verify(loading).invalidateResourcePhotos(9,"op",java.util.Set.of(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_SCENE, - com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_LIST)); + com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.RECEIPT)); + clearInvocations(loading); + service.invalidateLoading(9,"op",original,new Resources(null,null,null)); + verify(loading).invalidateResourcePhotos(9,"op",java.util.EnumSet.allOf(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.class)); } @Test void importsFrozenLicenseAndVehicleIntoSharedLoadingCategoriesWithoutDriverPortrait() throws Exception { var loading=mock(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.class); diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs index ad72fd20a..bc483c533 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs @@ -50,7 +50,7 @@ function model(fault = '') { } return response({...prior.result,replayed:true}) } - if (method === 'GET' && path.endsWith('/loading-documents')) return response({ dispatchNo: 'DS-ONE', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) }) + if (method === 'GET' && path.endsWith('/loading-documents')) return response({ dispatchNo: 'DS-ONE', stage: 'LOADING', reviewRound: '1', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) }) if (path === '/api/platform/v1/last-leg/fleet/trips/TRIP') return response(trip) if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE/items') return response({dispatchNo:'DS-ONE',recordVersion:nativeVersion,items:items()}) if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE') { diff --git a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs index c69001b39..25459da8f 100644 --- a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs @@ -87,7 +87,7 @@ function fixture(fault = '', nativeMode = false) { if (method === 'GET' && normalized === `${BASE}/DS-OWN` && path.startsWith(PDA)) return response({ ...data, items: items.map((i, n) => ({ ...i, positions: [{ positionId: `6${n}`, availablePackages: i.scheduledPackages }] })) }) if (method === 'GET' && normalized.endsWith('/items')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, status: 'ACTIVE', stage: data.stage, items: cargo }) if (method === 'GET' && normalized.endsWith('/route')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, stops: tripProof.stops }) - if (method === 'GET' && path.endsWith('/loading-documents')) return response({ dispatchNo: 'DS-OWN', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) }) + if (method === 'GET' && path.endsWith('/loading-documents')) return response({ dispatchNo: 'DS-OWN', stage: 'LOADING', reviewRound: '1', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) }) if (method === 'GET' && normalized.endsWith('/loading')) return response({ ...data, manifestHash: 'c'.repeat(64), loadingComplete: { allowed: true }, pallets: items.map((i, n) => ({ ...i, ...(fault === 'missing-measurements' ? {} : { palletSpaces: n === 2 ? 2 : 1, weightKg: n === 2 ? 37 : 10, volumeCbm: n === 2 ? 0.7 : 0.2, deliveryIndex: nativeMode || n === 2 ? 1 : n + 1, evidence: [['palletSpaces', n === 2 ? 2 : 1], ['weightKg', n === 2 ? 37 : 10], ['volumeCbm', n === 2 ? 0.7 : 0.2]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: i.inboundCode, observedAt: '2026-10-19T01:00:00Z' })) }) })) }) if (method === 'GET') { assert.ok(normalized === `${BASE}/DS-OWN` || normalized === `${BASE}/DS-FULL`, raw); return response(data) } diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs index a321b6008..130135394 100644 --- a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs @@ -114,7 +114,7 @@ function fixture(fault = '') { if (method === 'GET' && normalized.endsWith('/items')) return envelope({ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW', recordVersion: next ? 0 : recordVersion, status: 'ACTIVE', stage: next ? 'BEFORE_LOADING' : stage, items: next ? [] : [nativeItem()] }) if (method === 'GET' && normalized.endsWith('/route')) return envelope({ dispatchNo: 'DP-NEW', recordVersion, stops }) if (method === 'GET' && path.startsWith(PDA) && normalized.endsWith('/DP-NEW')) return envelope({ ...detail(false), items: [nativeItem()] }) - if (method === 'GET' && path.endsWith('/loading-documents')) return envelope({ dispatchNo: 'DP-NEW', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) }) + if (method === 'GET' && path.endsWith('/loading-documents')) return envelope({ dispatchNo: 'DP-NEW', stage: 'LOADING', reviewRound: '1', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) }) if (method === 'GET' && normalized.endsWith('/loading')) return envelope({ ...detail(false), manifestHash: 'b'.repeat(64), loadingComplete: { allowed: true }, pallets: [{ warehousePalletId: '100', palletNo: context.palletCode, scheduledPackages: 2, pickedPackages: 2, deliveryIndex: 1, ...(fault === 'missing-measurements' ? {} : { palletSpaces: 1, weightKg: 37, volumeCbm: 0.7, evidence: [['palletSpaces', 2], ['weightKg', 37], ['volumeCbm', 0.7]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: 'EX-NEW', observedAt: '2026-10-19T02:00:00Z' })) }) }] }) if (method === 'GET' && normalized.endsWith('/delivery-arrangement')) return envelope({ ...detail(false), cargoHash: 'a'.repeat(64), loadingCompletionHash: 'b'.repeat(64), arrange: { allowed: true }, groups: [{ groupKey: 'GROUP-A', customerId: CUSTOMER, destination, groupCargoHash: 'c'.repeat(64), externalFreightHash: 'd'.repeat(64), feeCoverageCargoHash: 'c'.repeat(64), blockReasons: [], requiredServices: ['DELIVERY_FEE'], cargo: [{ ...nativeItem(), packages: 2 }] }] }) diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs index 7efdfbe4d..5f55d4e43 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs @@ -20,7 +20,7 @@ const receipt = () => ({ environment: 'TEST', marker: 'TEST-NATIVE', dispatchNo: for (const initialStage of ['LOADING', 'AFTER_LOADING']) test(`loading documents fill missing slots without replacing existing files from ${initialStage}`, async () => { const proof = { marker: 'TEST-DOCS', dispatchNo: 'DP-A' }, calls = [], replays = new Map() - const state = { dispatchNo: 'DP-A', recordVersion: '9007199254740993', dispatchRecordVersion: '11', stage: initialStage, + const state = { dispatchNo: 'DP-A', recordVersion: '9007199254740993', dispatchRecordVersion: '11', reviewRound: '1', stage: initialStage, complete: false, editable: true, photos: [{ slot: LOADING_PHOTO_SLOTS[0], fileId: 'existing' }] } let saved = false const context = { async checkpoint() { saved = true }, @@ -28,7 +28,7 @@ for (const initialStage of ['LOADING', 'AFTER_LOADING']) test(`loading documents async requestMultipart(method, path, file, token) { assert.ok(saved); saved = false; assert.equal(token, 'PDA-TEST'); assert.equal(method, 'POST') const slot = path.split('/').at(-2) - assert.match(path, /idempotencyKey=[a-f0-9]{64}$/); assert.equal(file.type, 'image/png') + assert.match(path, /idempotencyKey=[a-f0-9]{64}&reviewRound=1$/); assert.equal(file.type, 'image/png') assert.equal(file.content.subarray(1, 4).toString(), 'PNG'); calls.push(slot) assert.equal(proof.loadingDocuments.uploads.at(-1).status, 'PREPARED') return ok({ slot, fileId: `new-${slot}` }) @@ -39,6 +39,7 @@ for (const initialStage of ['LOADING', 'AFTER_LOADING']) test(`loading documents assert.equal(method, 'PUT') if (replays.has(body.idempotencyKey)) return ok(replays.get(body.idempotencyKey)) assert.equal(body.recordVersion, state.recordVersion) + assert.equal(body.uploadKey, new URL(proof.loadingDocuments.uploads.at(-1).path, 'https://unused.test').searchParams.get('idempotencyKey')) state.photos.push({ slot: path.split('/').at(-1), fileId: body.fileId }) state.recordVersion = String(BigInt(state.recordVersion) + 1n) state.complete = state.photos.length === 5 @@ -54,7 +55,7 @@ for (const initialStage of ['LOADING', 'AFTER_LOADING']) test(`loading documents for (const fault of ['checkpoint', 'upload-unknown', 'wrong-slot', 'replay-drift', 'early-departure']) test(`loading document ${fault} stops before loading confirmation`, async () => { const proof = { marker: 'TEST-DOCS', dispatchNo: 'DP-A' }, calls = [] - const state = { dispatchNo: 'DP-A', stage: 'LOADING', recordVersion: '1', complete: false, editable: true, photos: [] } + const state = { dispatchNo: 'DP-A', stage: 'LOADING', reviewRound: '1', recordVersion: '1', complete: false, editable: true, photos: [] } const context = { async checkpoint() { if (fault === 'checkpoint') throw Error('checkpoint failed') }, assert(_m, label, _r, passed) { assert.ok(passed, label) }, apiSuccess(_m, _l, response) { return response?.body?.data }, async requestMultipart(_method, path) { @@ -127,7 +128,7 @@ function server(fault = '') { }, async request(method, path, body, token) { calls.push({ method, path, body: structuredClone(body), actor: token ? 'PDA' : 'PLATFORM' }) - if (method === 'GET' && path.endsWith('/loading-documents')) return ok({ dispatchNo: 'DP-A', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) }) + if (method === 'GET' && path.endsWith('/loading-documents')) return ok({ dispatchNo: 'DP-A', stage: 'LOADING', reviewRound: '1', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) }) if (method === 'GET' && path.endsWith(`/master-data/vehicles/${VID}`)) return ok({ id: VID, status: 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED' }) if (method === 'GET' && path.includes(`/fleet/drivers/${DRIVER}?`)) return ok({ id: DRIVER, active: true, reviewStatus: 'APPROVED', details: { ownership: 'OWNED' }, qualification: { canDispatch: true, reason: 'ELIGIBLE' } }) if (fault === 'unknown-edit' && method === 'PUT' && path === D) throw new Error('transport result unknown') diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs index 8f2c05570..2fbd99fc1 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs @@ -296,9 +296,10 @@ export async function ensureFleetLoadingDocuments(context, module, proof, token) const read = () => command(context, module, 'GET', path, undefined, '回查本车装车资料', token) let documents = await read() const initialStage = documents.stage + const reviewRound = id(documents.reviewRound) const complete = value => LOADING_PHOTO_SLOTS.every(slot => value.photos?.some(photo => photo.slot === slot && id(photo.fileId))) const validate = value => { - if (value.dispatchNo !== proof.dispatchNo || !['LOADING', 'AFTER_LOADING', 'DELIVERING'].includes(value.stage) + if (value.dispatchNo !== proof.dispatchNo || !/^[1-9]\d*$/.test(reviewRound) || id(value.reviewRound) !== reviewRound || !['LOADING', 'AFTER_LOADING', 'DELIVERING'].includes(value.stage) || value.complete !== complete(value)) throw Error('本车装车资料身份、阶段或五框完整性不匹配') } validate(documents) @@ -307,7 +308,8 @@ export async function ensureFleetLoadingDocuments(context, module, proof, token) if (documents.photos?.some(photo => photo.slot === slot && id(photo.fileId))) continue if (!documents.editable || typeof context.requestMultipart !== 'function') throw Error('缺少本车装车照片写入资格或上传能力') const content = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') - const upload = { slot, path: `${path}/${slot}/uploads?idempotencyKey=${key(proof, `PHOTO_UPLOAD_${slot}`)}`, + const uploadKey = key(proof, `PHOTO_UPLOAD_${slot}`) + const upload = { slot, path: `${path}/${slot}/uploads?idempotencyKey=${uploadKey}&reviewRound=${reviewRound}`, name: `${proof.marker}-TEST-${slot}.png`, sha256: createHash('sha256').update(content).digest('hex'), status: 'PREPARED' } if (proof.loadingDocuments.uploads.some(item => item.slot === slot)) throw Error('该照片上传已开始,保留原键和结果排查,不自动重发') proof.loadingDocuments.uploads.push(upload); await context.checkpoint?.() @@ -319,7 +321,7 @@ export async function ensureFleetLoadingDocuments(context, module, proof, token) await context.checkpoint?.() const photo = context.apiSuccess(module, '上传本轮TEST合成装车照片', response, '', OPTIONS) if (response?.http !== 200 || String(response?.body?.code) !== '200' || photo?.slot !== slot || !id(photo.fileId)) throw Error('装车照片上传未确认,停止绑定及发车') - const body = { recordVersion: documents.recordVersion, idempotencyKey: key(proof, `PHOTO_BIND_${slot}`), fileId: id(photo.fileId) } + const body = { recordVersion: documents.recordVersion, idempotencyKey: key(proof, `PHOTO_BIND_${slot}`), fileId: id(photo.fileId), uploadKey } const bound = await command(context, module, 'PUT', `${path}/${slot}`, body, '保存本车装车照片', token) validate(bound) check(context, module, '绑定回执保留本槽文件', bound.photos.some(item => item.slot === slot && id(item.fileId) === id(photo.fileId))) diff --git a/scripts/dispatch-loading-flow-test-env.mjs b/scripts/dispatch-loading-flow-test-env.mjs index b2f7e22e5..7ad3f8475 100644 --- a/scripts/dispatch-loading-flow-test-env.mjs +++ b/scripts/dispatch-loading-flow-test-env.mjs @@ -140,10 +140,12 @@ export async function runLoadingFlow(config, env=process.env) { const ledger=async()=>Promise.all(report.members.map(m=>send('GET',`${ROOT}/inventory/ledger?`+new URLSearchParams({customerId:m.customerId,overseasWarehouseId:config.warehouseId,inboundCode:m.incomingNo,page:'1',size:'100'})))) report.stockAfterPick=await ledger() phase('PDA_PHOTOS_AND_ADMIN_FILE_READBACK') - for(const slot of slots){const up=await upload(`${pp}/loading-documents/${slot}/uploads?idempotencyKey=${key('UPLOAD-'+slot)}`,`${config.runId}-TEST-${slot}.png`,picker.token);const docs=await send('GET',pp+'/loading-documents',undefined,picker.token);await send('PUT',`${pp}/loading-documents/${slot}`,{recordVersion:docs.recordVersion,idempotencyKey:key('BIND-'+slot),fileId:id(up.fileId)},picker.token)} - const docs=await send('GET',path+'/loading-documents');check('Admin sees five photos',docs.complete && docs.photos.length===5) + const initialDocuments=await send('GET',pp+'/loading-documents',undefined,picker.token),reviewRound=id(initialDocuments.reviewRound),uploadedFiles=new Set() + assert.match(reviewRound,/^[1-9]\d*$/) + for(const slot of slots){const uploadKey=key('UPLOAD-'+slot);const up=await upload(`${pp}/loading-documents/${slot}/uploads?idempotencyKey=${uploadKey}&reviewRound=${reviewRound}`,`${config.runId}-TEST-${slot}.png`,picker.token);const docs=await send('GET',pp+'/loading-documents',undefined,picker.token);assert.equal(id(docs.reviewRound),reviewRound);await send('PUT',`${pp}/loading-documents/${slot}`,{recordVersion:docs.recordVersion,idempotencyKey:key('BIND-'+slot),fileId:id(up.fileId),uploadKey},picker.token);uploadedFiles.add(id(up.fileId))} + const docs=await send('GET',path+'/loading-documents');check('Admin sees imported and five added photos',docs.complete && docs.photos.length===initialDocuments.photos.length+5) report.photos=[] - for(const photo of docs.photos){const r=await context.requestBinary('GET',`${path}/loading-documents/${photo.slot}/files/${photo.fileId}/content`);check('Admin bytes '+photo.slot,r.http===200 && hash(r.bytes)===hash(image));report.photos.push({slot:photo.slot,fileId:photo.fileId,sha256:hash(r.bytes)})} + for(const photo of docs.photos){const r=await context.requestBinary('GET',`${path}/loading-documents/${photo.slot}/files/${photo.fileId}/content`);check('Admin bytes '+photo.slot,r.http===200 && r.bytes.length>0 && (!uploadedFiles.has(id(photo.fileId)) || hash(r.bytes)===hash(image)));report.photos.push({slot:photo.slot,fileId:photo.fileId,sha256:hash(r.bytes)})} phase('AUTOMATIC_OUTBOUND_AND_DELIVERY') const preview=await send('GET',pp+'/loading',undefined,picker.token),body={recordVersion:preview.recordVersion,manifestHash:preview.manifestHash,idempotencyKey:key('LOAD'),remark:'TEST scanned pallets; no manual measurements'} const loaded=await send('POST',pp+'/loading-complete',body,picker.token);check('automatic departure',loaded.stage==='DELIVERING')