fix(dispatch): invalidate resource evidence and bind uploads to review rounds
This commit is contained in:
+2
-2
@@ -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<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,
|
||||
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
|
||||
return ApiResponse.success(service.upload(DispatchActorFactory.platform(user),dispatchNo,slot,idempotencyKey,file));
|
||||
@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}")
|
||||
|
||||
+2
-2
@@ -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<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,
|
||||
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
|
||||
return ApiResponse.success(service.upload(DispatchActorFactory.pda(user),dispatchNo,slot,idempotencyKey,file));
|
||||
@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}")
|
||||
|
||||
+7
-4
@@ -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<Photo> photos) {
|
||||
|
||||
+12
@@ -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()));
|
||||
|
||||
+5
-5
@@ -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<Photo> photos) {}
|
||||
record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String stage,boolean editable,boolean complete,List<Photo> photos) {}
|
||||
record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String reviewRound,String stage,boolean editable,boolean complete,List<Photo> 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<Slot> 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);
|
||||
/** 文件只可从本车当前已绑定槽位下载。 */
|
||||
|
||||
+21
-8
@@ -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<Slot> 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("待替换文件编号无效");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+42
-13
@@ -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,"实装发车");}
|
||||
|
||||
+8
-3
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user