fix(dispatch): invalidate resource evidence and bind uploads to review rounds

This commit is contained in:
2026-10-26 19:21:11 +08:00
parent f6d9991955
commit a5befc9bad
15 changed files with 138 additions and 51 deletions
@@ -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<Future<Boolean>>();
for(String key:List.of("admin-last","pda-last"))results.add(workers.submit(()->{
authorize();try {start.await(5,TimeUnit.SECONDS);var actor=key.startsWith("pda")?new Actor("picker",ActorMode.PDA,"9"):f.f.actor;
f.f.tx.execute(s->f.documents.bind(actor,f.number,Slot.RECEIPT,new BindPhoto("4",key,"1004")));return true;
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,"实装发车");}
@@ -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);