优化
This commit is contained in:
@@ -15,7 +15,7 @@ enum BusinessFileCategory {
|
||||
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||
|
||||
@@ -7,7 +7,7 @@ info:
|
||||
复用InternalBusinessFileController,不增加公网File路径。
|
||||
businessType分为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO,
|
||||
tenantId固定0,businessId固定原Lastleg排车内部主键,不接受车次主键、车牌或客户ID替代。
|
||||
仅非空JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。
|
||||
装车资料接受非空JPG/PNG/PDF(Lastleg仅允许司机驾驶证类别使用PDF),油耗仍仅接受JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。
|
||||
Lastleg按公开dispatchNo解析原排车,负责车辆归属、可见阶段、读写权限、附加记录版本、
|
||||
分类附件引用和历史。文件上传不代表装车确认、签收、实到或运营信息保存。
|
||||
File不读取Lastleg表,只按客户范围、业务类别、业务ID和文件ID核对文件复合归属。
|
||||
|
||||
@@ -22,6 +22,12 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
class BusinessFileStorageTest {
|
||||
@TempDir Path root;
|
||||
@Test void loadingLicenseStoresPdfButFuelStillRejectsPdf() {
|
||||
var file=new MockMultipartFile("file","license.pdf","application/pdf","%PDF-1.4\n%%EOF".getBytes());
|
||||
var stored=storage().store(0L,"51",file,BusinessFileCategory.OVERSEAS_DISPATCH_LOADING_PHOTO);
|
||||
assertThat(stored.contentType()).isEqualTo("application/pdf");
|
||||
assertThatThrownBy(()->storage().store(0L,"51",file,BusinessFileCategory.OVERSEAS_DISPATCH_FUEL_PHOTO)).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
@Test void arbitraryCustomsFormatIsStoredAsAttachmentObject() {
|
||||
BusinessFileStorage storage = storage();
|
||||
|
||||
+5
-1
@@ -452,6 +452,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
if(savedDriver!=null && savedDriver.evidence()!=null)for(var id:savedDriver.evidence().fileIds()) {
|
||||
if(!Objects.equals(id,savedDriver.evidence().masterLicenseFileId()))evidence.metadata(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,id);
|
||||
}
|
||||
masterPhotos.invalidateLoading(row.getId(),actor.operatorId(),read(row.getResourcesJson(),Resources.class),savedResources);
|
||||
row.setResourcesJson(write(savedResources));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
|
||||
row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark());
|
||||
// 发布选择仅在建单时确定,维护车辆不得取消或重新发布需求。
|
||||
@@ -504,7 +505,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var reviewed=new Review(request.decision()==Decision.APPROVED?ReviewStatus.APPROVED:ReviewStatus.REJECTED,trim(request.reason()),actor.operatorId(),Instant.now().truncatedTo(ChronoUnit.MILLIS),row.getReviewRound(),row.getReviewBasisHash(),null,request.attachments());
|
||||
if(loading) {
|
||||
row.setLoadingReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.APPROVED.name():ReviewState.LOADING_REJECTED.name());
|
||||
if(request.decision()==Decision.APPROVED)row.setStage(Stage.LOADING.name());
|
||||
if(request.decision()==Decision.APPROVED) {
|
||||
row.setStage(Stage.LOADING.name());
|
||||
if(!"PICKUP".equals(row.getTaskType()))masterPhotos.populateLoading(row.getId(),actor.operatorId(),read(row.getResourcesJson(),Resources.class));
|
||||
}
|
||||
} else {
|
||||
row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name());
|
||||
row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null,null):pending(row.getReviewRound())));
|
||||
|
||||
+58
@@ -8,6 +8,11 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Objects;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode.MASTER;
|
||||
|
||||
@@ -16,10 +21,63 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode.MASTER
|
||||
public class DispatchMasterPhotoService {
|
||||
private final FleetResourcePhotoPort photos;
|
||||
private final DispatchEvidenceGateway evidence;
|
||||
@jakarta.annotation.Resource private DispatchLoadingPhotoPort loadingPhotos;
|
||||
@jakarta.annotation.Resource private FleetTripExecutionService fleet;
|
||||
/** 只读主档并通过本车文件网关创建证据。 */
|
||||
public DispatchMasterPhotoService(FleetResourcePhotoPort photos,DispatchEvidenceGateway evidence) {
|
||||
this.photos=photos; this.evidence=evidence;
|
||||
}
|
||||
/** 资源保存时比较本趟资料来源;来源变化后旧类别不能继续充当新资源的凭证。 */
|
||||
public void invalidateLoading(long dispatchId,String operator,Resources before,Resources after) {
|
||||
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(!slots.isEmpty())loadingPhotos.invalidateResourcePhotos(dispatchId,operator,slots);
|
||||
}
|
||||
/** 只比较车辆身份及照片,不因容量或运营状态变更废弃现场资料。 */
|
||||
private Object vehicleSource(VehicleSnapshot value) {
|
||||
return value==null?null:java.util.Arrays.asList(value.mode(),value.vehicleId(),value.plate(),sourceFiles(value.evidenceFileIds()));
|
||||
}
|
||||
/** 临时司机同样比较身份与证照;人像、电话和预计结束时间不属于驾驶证来源。 */
|
||||
private Object driverSource(DriverSnapshot value) {
|
||||
if(value==null)return null;
|
||||
var evidence=value.evidence();
|
||||
return java.util.Arrays.asList(value.mode(),value.driverId(),value.name(),evidence==null?null:evidence.licenseNo(),
|
||||
evidence==null?null:sourceFiles(evidence.fileIds()),evidence==null?null:evidence.masterLicenseFileId());
|
||||
}
|
||||
/** 空附件与未提供附件等价,调整顺序不改变资料来源。 */
|
||||
private java.util.Set<Long> sourceFiles(List<Long> ids) { return ids==null?java.util.Set.of():java.util.Set.copyOf(ids); }
|
||||
/** 理货通过后把已审核的本趟快照复制进装车资料;不读取后来变更的主档、不复制司机人像。 */
|
||||
public void populateLoading(long dispatchId,String operator,Resources value) {
|
||||
var saved=loadingPhotos.read(dispatchId).photos();
|
||||
var driver=value.driver();
|
||||
if(driver!=null && driver.evidence()!=null && saved.stream().noneMatch(p->p.slot()==Slot.LOADING_SCENE)) {
|
||||
var files=new ArrayList<MultipartFile>();
|
||||
for(var id:driver.evidence().fileIds().stream().distinct().limit(5).toList()) {
|
||||
if(driver.mode()==MASTER && driver.driverId()!=null && Objects.equals(id,driver.evidence().masterLicenseFileId())) {
|
||||
var source=fleet.dispatchDriverLicense(driver.driverId(),id);
|
||||
files.add(new SnapshotFile(source.metadata().fileName(),source.metadata().contentType(),source.content()));
|
||||
} else files.add(snapshot(dispatchId,DispatchEvidenceGateway.Kind.DRIVER,id));
|
||||
}
|
||||
loadingPhotos.importIfEmpty(dispatchId,operator,Slot.LOADING_SCENE,files);
|
||||
}
|
||||
var vehicle=value.vehicle();
|
||||
if(vehicle!=null && saved.stream().noneMatch(p->p.slot()==Slot.LOADING_LIST)) {
|
||||
var files=new ArrayList<MultipartFile>();
|
||||
for(var id:vehicle.evidenceFileIds().stream().distinct().toList()) {
|
||||
var metadata=evidence.metadata(dispatchId,DispatchEvidenceGateway.Kind.VEHICLE,id);
|
||||
if(!List.of("image/jpeg","image/png").contains(metadata.contentType()))continue;
|
||||
files.add(new SnapshotFile(metadata.fileName(),metadata.contentType(),evidence.download(dispatchId,DispatchEvidenceGateway.Kind.VEHICLE,id)));
|
||||
if(files.size()==5)break;
|
||||
}
|
||||
loadingPhotos.importIfEmpty(dispatchId,operator,Slot.LOADING_LIST,files);
|
||||
}
|
||||
}
|
||||
/** 复制前再次核验本趟及证据分类,不把外部文件编号直接绑定为装车资料。 */
|
||||
private MultipartFile snapshot(long dispatchId,DispatchEvidenceGateway.Kind kind,long id) {
|
||||
var metadata=evidence.metadata(dispatchId,kind,id);
|
||||
return new SnapshotFile(metadata.fileName(),metadata.contentType(),evidence.download(dispatchId,kind,id));
|
||||
}
|
||||
/** 保存前补入已有档案照片;临时资源和已有当趟照片保持原样。 */
|
||||
public Resources populate(long dispatchId,String operator,Resources value) {
|
||||
var v=value.vehicle(); var d=value.driver();
|
||||
|
||||
+8
-7
@@ -28,15 +28,15 @@ public class HttpOwnedBusinessFileGateway implements OwnedBusinessFileGateway {
|
||||
client=factory.build(builder,baseUrl,properties.getCustomerVisitFile());this.token=token;
|
||||
}
|
||||
/** MIME和字节签名一起校验,File仍执行其权威校验。 */
|
||||
public static void validate(Upload upload) {
|
||||
public static void validate(PhotoSlot slot,Upload upload) {
|
||||
if(upload==null || upload.fileName()==null || upload.fileName().isBlank() || upload.fileName().length()>255
|
||||
|| upload.content()==null || upload.content().length==0 || upload.content().length>MAX_SIZE
|
||||
|| !image(upload.contentType()) || !signature(upload.contentType(),upload.content()))
|
||||
throw bad("照片须为非空真实JPG/PNG,最大10MiB");
|
||||
|| !accepted(slot,upload.contentType()) || !signature(upload.contentType(),upload.content()))
|
||||
throw bad("资料须为真实JPG/PNG;司机驾驶证也支持PDF,最大10MiB");
|
||||
}
|
||||
/** 使用原图片字节,未知结果不能换键或自动重传。 */
|
||||
@Override public Evidence upload(long dispatchId,PhotoSlot slot,String operatorId,Upload upload) {
|
||||
validate(upload);var body=new MultipartBodyBuilder();
|
||||
validate(slot,upload);var body=new MultipartBodyBuilder();
|
||||
body.part("file",new ByteArrayResource(upload.content()){
|
||||
/** 保留本轮原文件名,不生成占位证据。 */
|
||||
@Override public String getFilename(){return upload.fileName();}
|
||||
@@ -64,15 +64,16 @@ public class HttpOwnedBusinessFileGateway implements OwnedBusinessFileGateway {
|
||||
var m=response.data();
|
||||
if(m.fileId()==null || m.fileId()<=0 || !Long.valueOf(0).equals(m.tenantId()) || !slot.businessType().equals(m.businessType())
|
||||
|| !String.valueOf(dispatchId).equals(m.businessId()) || expected!=null && !expected.equals(m.fileId())
|
||||
|| m.sizeBytes()==null || m.sizeBytes()<=0 || m.sizeBytes()>MAX_SIZE || !image(m.contentType())
|
||||
|| m.sizeBytes()==null || m.sizeBytes()<=0 || m.sizeBytes()>MAX_SIZE || !accepted(slot,m.contentType())
|
||||
|| m.fileName()==null || m.fileName().isBlank() || m.createdAt()==null)throw bad("照片不属于本排车或分类,或文件格式无效");
|
||||
return new Evidence(m.fileId(),m.fileName(),m.contentType(),m.sizeBytes(),m.createdAt().toString());
|
||||
}
|
||||
/** 不放开PDF、SVG或任意image类型。 */
|
||||
private static boolean image(String type){return Set.of("image/jpeg","image/png").contains(type==null?"":type);}
|
||||
/** 仅驾驶证类别接受PDF,其他装车及油耗类别仍只接受JPG/PNG。 */
|
||||
private static boolean accepted(PhotoSlot slot,String type){return Set.of("image/jpeg","image/png").contains(type==null?"":type) || slot==PhotoSlot.LOADING_SCENE && "application/pdf".equals(type);}
|
||||
/** 使用真实文件头,不转换或伪造证据图片。 */
|
||||
private static boolean signature(String type,byte[] bytes) {
|
||||
if(bytes==null)return false;
|
||||
if("application/pdf".equals(type))return bytes.length>=5 && bytes[0]=='%' && bytes[1]=='P' && bytes[2]=='D' && bytes[3]=='F' && bytes[4]=='-';
|
||||
if("image/jpeg".equals(type))return bytes.length>=3 && (bytes[0]&255)==255 && (bytes[1]&255)==216 && (bytes[2]&255)==255;
|
||||
byte[] png={(byte)137,80,78,71,13,10,26,10};if(!"image/png".equals(type) || bytes.length<png.length)return false;
|
||||
for(int i=0;i<png.length;i++)if(bytes[i]!=png[i])return false;return true;
|
||||
|
||||
+4
@@ -18,6 +18,10 @@ public interface DispatchLoadingPhotoPort {
|
||||
record Download(Photo photo,byte[] content) {}
|
||||
/** 只读取原整车附加资料版本与五框,不初始化记录。 */
|
||||
State read(long dispatchId);
|
||||
/** 已持排车父锁;仅为空类别复制本趟已核验资料,与理货审核同事务保存。 */
|
||||
void importIfEmpty(long dispatchId,String operator,Slot slot,List<MultipartFile> files);
|
||||
/** 已持排车父锁;资源变化使对应当前资料失效,旧文件与版本历史保留。 */
|
||||
void invalidateResourcePhotos(long dispatchId,String operator,java.util.Set<Slot> slots);
|
||||
/** 上传保存原请求意图;不提前计入已保存照片。 */
|
||||
Photo upload(long dispatchId,String operator,Slot slot,String key,MultipartFile file);
|
||||
/** 仅原操作者可恢复原上传结果。 */
|
||||
|
||||
+33
-1
@@ -31,6 +31,38 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort {
|
||||
this.repository=repository;this.commands=commands;this.projection=projection;this.codec=codec;this.files=files;
|
||||
}
|
||||
public record Binding(Slot slot,BindPhoto request) {}
|
||||
/** 与资源修改同事务失效旧凭证;不变类别及历史保留,旧资料版本不得继续保存。 */
|
||||
@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);
|
||||
var current=projection.photos(row);
|
||||
var kept=current.stream().filter(photo->slots.stream().noneMatch(slot->photo.slot()==slot(slot))).toList();
|
||||
if(kept.size()==current.size())return;
|
||||
long version=row.getRecordVersion();
|
||||
row.setPhotosJson(codec.write(kept));row.setRecordVersion(Math.addExact(version,1));
|
||||
row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now()));
|
||||
if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败");
|
||||
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(id);history.setRecordVersion(row.getRecordVersion());
|
||||
history.setAction("INVALIDATE_RESOURCE_PHOTOS");history.setOperatorId(operator);history.setReason("车辆或司机资料变化,原凭证失效");history.setOccurredAt(row.getUpdatedAt());
|
||||
history.setSnapshotJson(codec.write(new OwnedBusinessModels.Snapshot(projection.operations(row),kept,operator)));repository.history(history);
|
||||
}
|
||||
/** 自动带入只填空类;重复审核及已有现场照片不重复追加或覆盖。 */
|
||||
@Override @Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED)
|
||||
public void importIfEmpty(long id,String operator,Slot slot,List<MultipartFile> sources) {
|
||||
var state=read(id);
|
||||
if(state.photos().stream().anyMatch(photo->photo.slot()==slot) || sources.isEmpty())return;
|
||||
if(sources.size()>5)throw bad("每类装车资料最多5份");
|
||||
for(var source:sources) {
|
||||
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())));
|
||||
} catch(IOException failure) { throw bad("读取本趟资料失败,未完成带入"); }
|
||||
}
|
||||
}
|
||||
/** GET只读;首次保存前资料版本为0。 */
|
||||
@Override public State read(long id) {
|
||||
var row=repository.record(id);
|
||||
@@ -43,7 +75,7 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort {
|
||||
if(multipart==null || multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB");
|
||||
byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException failure){throw bad("无法读取待上传照片");}
|
||||
var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes);
|
||||
HttpOwnedBusinessFileGateway.validate(payload);
|
||||
HttpOwnedBusinessFileGateway.validate(originalSlot,payload);
|
||||
var identity=new OwnedBusinessPhotoService.UploadIdentity(originalSlot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes));
|
||||
var reservation=commands.reserveUpload(id,operator(operator),key,identity);
|
||||
if(!reservation.created())return uploaded(reservation.command());
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ public class OwnedBusinessPhotoService {
|
||||
if(multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB");
|
||||
byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException error){throw bad("无法读取待上传照片");}
|
||||
var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes);
|
||||
HttpOwnedBusinessFileGateway.validate(payload);
|
||||
HttpOwnedBusinessFileGateway.validate(slot,payload);
|
||||
var identity=new UploadIdentity(slot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes));
|
||||
var reservation=commands.reserveUpload(source.getId(),actor,key,identity);
|
||||
if(!reservation.created()) {
|
||||
|
||||
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: 排车装车资料(Admin/PDA共用)
|
||||
version: 1.0.0
|
||||
description: LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位沿用原文件归属,按顺序对应司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;每类1至5张,允许追加或指定replaceFileId精确替换,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。
|
||||
description: 理货审核通过时,将本趟已核验驾驶证和车辆照片复制到空的司机驾驶证、车牌照片类别(每类最多5份),来源未变的已有照片保持不变;保存车辆、司机或证照来源变更时,对应类别旧资料与资源保存同事务失效并保留历史,重审通过后带入新资料;PDF驾驶证保留原件并计为一份。GET只读,不对存量记录补写。LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位沿用原文件归属,按顺序对应司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;每类1至5张,允许追加或指定replaceFileId精确替换,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
@@ -84,7 +84,7 @@ paths:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
description: 非空JPG/PNG,最大10MiB
|
||||
description: 非空JPG/PNG,司机驾驶证(LOADING_SCENE)也支持PDF,最大10MiB
|
||||
idempotencyKey:
|
||||
type: string
|
||||
minLength: 8
|
||||
@@ -124,7 +124,7 @@ paths:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
description: 非空JPG/PNG,最大10MiB
|
||||
description: 非空JPG/PNG,司机驾驶证(LOADING_SCENE)也支持PDF,最大10MiB
|
||||
idempotencyKey:
|
||||
type: string
|
||||
minLength: 8
|
||||
@@ -204,6 +204,10 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
application/pdf:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
image/png:
|
||||
schema:
|
||||
type: string
|
||||
@@ -225,6 +229,10 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
application/pdf:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
image/png:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
@@ -75,7 +75,7 @@ paths:
|
||||
type: object
|
||||
required: [file, idempotencyKey]
|
||||
properties:
|
||||
file: { type: string, format: binary, description: "非空JPG/PNG,签名校验,最大10MiB;固定装车File分类" }
|
||||
file: { type: string, format: binary, description: "非空JPG/PNG,司机驾驶证(LOADING_SCENE)也支持PDF,签名校验,最大10MiB;固定装车File分类" }
|
||||
idempotencyKey: { type: string, minLength: 8, maxLength: 128 }
|
||||
responses:
|
||||
"200":
|
||||
@@ -110,6 +110,7 @@ paths:
|
||||
description: "原始照片字节,Cache-Control=no-store,X-Content-Type-Options=nosniff;无引用不允许探测。"
|
||||
content:
|
||||
image/png: { schema: { type: string, format: binary } }
|
||||
application/pdf: { schema: { type: string, format: binary } }
|
||||
image/jpeg: { schema: { type: string, format: binary } }
|
||||
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/commands/{idempotencyKey}:
|
||||
parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ]
|
||||
@@ -202,7 +203,7 @@ components:
|
||||
category: { type: string, enum: [LOADING] }
|
||||
fileId: { type: string }
|
||||
fileName: { type: string }
|
||||
contentType: { type: string, enum: [image/png, image/jpeg] }
|
||||
contentType: { type: string, enum: [image/png, image/jpeg, application/pdf] }
|
||||
sizeBytes: { type: string }
|
||||
uploadedAt: { type: string, format: date-time }
|
||||
savedAt: { type: string, format: date-time, nullable: true }
|
||||
|
||||
@@ -255,7 +255,7 @@ paths:
|
||||
file:
|
||||
type: "string"
|
||||
format: "binary"
|
||||
description: "真实JPG/PNG,非空,最大10MiB;与独立绑定命令使用不同请求键。"
|
||||
description: "真实JPG/PNG,司机驾驶证(LOADING_SCENE)也支持PDF,非空,最大10MiB;与独立绑定命令使用不同请求键。"
|
||||
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/photos/{slot}":
|
||||
parameters:
|
||||
-
|
||||
@@ -334,6 +334,8 @@ paths:
|
||||
schema:
|
||||
type: "string"
|
||||
format: "binary"
|
||||
"application/pdf":
|
||||
schema: { type: string, format: binary }
|
||||
"image/png":
|
||||
schema:
|
||||
type: "string"
|
||||
@@ -642,6 +644,7 @@ components:
|
||||
enum:
|
||||
- "image/jpeg"
|
||||
- "image/png"
|
||||
- "application/pdf"
|
||||
sizeBytes:
|
||||
type: "string"
|
||||
pattern: "^[1-9][0-9]*$"
|
||||
|
||||
+4
-1
@@ -642,7 +642,10 @@ public class DispatchAggregatePersistenceTest {
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",dispatchEvidence);
|
||||
var profilePhotos=mock(com.baiyue.lastleg.fleet.photo.FleetResourcePhotoService.class);
|
||||
when(profilePhotos.current(any(),anyLong())).thenReturn(new com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Detail(0,null));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"masterPhotos",new DispatchMasterPhotoService(profilePhotos,dispatchEvidence));
|
||||
var masterPhotos=org.mockito.Mockito.spy(new DispatchMasterPhotoService(profilePhotos,dispatchEvidence));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(masterPhotos,"loadingPhotos",mock(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.class));
|
||||
org.mockito.Mockito.doNothing().when(masterPhotos).populateLoading(anyLong(),anyString(),any());
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"masterPhotos",masterPhotos);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"ownedBusinessOwnership",ownedBusinessOwnership);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)));
|
||||
|
||||
+70
-4
@@ -30,6 +30,68 @@ import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
/** 装车后五框门禁、保存发车共同事务及跨端竞争使用真实单据和版本。 */
|
||||
class DispatchLoadingDeparturePersistenceTest {
|
||||
/** 零扫码退回后换资源,旧凭证失效与资源保存原子提交;重审自动带入新资料供两端共用。 */
|
||||
@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 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());
|
||||
var evidence=(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.f.service,"evidence");
|
||||
when(evidence.download(anyLong(),any(),anyLong())).thenReturn(new byte[]{1,2,3});
|
||||
var nextId=new java.util.concurrent.atomic.AtomicLong(3000);
|
||||
when(f.files.upload(eq(id),any(),eq(f.f.actor.operatorId()),any())).thenAnswer(call->new OwnedBusinessFileGateway.Evidence(nextId.incrementAndGet(),"new.png","image/png",3,"2026-10-26T00:00:00Z"));
|
||||
var returned=f.f.tx.execute(s->f.f.service.returnToReview(f.f.actor,f.number,new DispatchCommands.ReturnToReview(f.version,"return-change-resources","更换车辆司机")));
|
||||
var input=f.f.configured("new-resources",List.of());var evidenceInput=input.driver().evidence();
|
||||
var vehicle=new DispatchCommands.Vehicle(ResourceMode.EXTERNAL,null,"NEW-TRUCK","新承运商",input.vehicle().capacity(),List.of(111L));
|
||||
var driver=new DispatchCommands.Driver(ResourceMode.EXTERNAL,null,"新司机","987654321",new DriverEvidence("NEW-LICENSE",evidenceInput.licenseExpiresOn(),List.of(222L),evidenceInput.expectedTaskEndAt(),List.of(333L)));
|
||||
var request=new DispatchCommands.Update(returned.recordVersion(),"change-resources",vehicle,driver,input.plannedDepartureAt(),null,null,null);
|
||||
var previousResources=f.f.repository.find(f.number).getResourcesJson();
|
||||
assertThatThrownBy(()->f.f.tx.execute(s->{f.f.service.update(f.f.actor,f.number,request);throw new IllegalStateException("模拟后续保存失败");})).hasMessage("模拟后续保存失败");
|
||||
assertThat(f.photos.read(id)).isEqualTo(old);
|
||||
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(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);
|
||||
assertThat(f.f.db.queryForObject("SELECT snapshot_json FROM ll_fleet_owned_business_history WHERE dispatch_id=? AND record_version=5",String.class,id)).contains("1000","1001");
|
||||
f.f.prepareAppointmentNumbers(f.number);
|
||||
var submitted=f.f.tx.execute(s->f.f.service.submitReview(f.f.actor,f.number,new DispatchCommands.SubmitReview(updated.recordVersion(),"resubmit-changed",null)));
|
||||
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");
|
||||
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("其他操作更新");
|
||||
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());
|
||||
}
|
||||
@Test void importedFilesAreSharedAcrossActorsAndCountTowardDepartureWithoutDuplicateCopies() throws Exception {
|
||||
authorize();var f=new Fixture();long id=f.f.repository.find(f.number).getId();
|
||||
var image=new org.springframework.mock.web.MockMultipartFile("file","plate.png","image/png",new byte[]{1,2,3});
|
||||
when(f.files.upload(eq(id),any(),eq("operator"),any())).thenAnswer(call->new OwnedBusinessFileGateway.Evidence(
|
||||
call.getArgument(1)==com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot.LOADING_SCENE?2000:2001,"copied.png","image/png",3,"2026-10-26T00:00:00Z"));
|
||||
f.f.tx.executeWithoutResult(s->{
|
||||
f.photos.importIfEmpty(id,"operator",Slot.LOADING_SCENE,List.of(image));
|
||||
f.photos.importIfEmpty(id,"operator",Slot.LOADING_LIST,List.of(image));
|
||||
});
|
||||
var admin=f.documents.get(f.f.actor,f.number);
|
||||
var pda=f.documents.get(new Actor("picker",ActorMode.PDA,"9"),f.number);
|
||||
assertThat(pda.photos()).isEqualTo(admin.photos()).hasSize(2);
|
||||
assertThat(admin.complete()).isFalse();assertThat(admin.recordVersion()).isEqualTo("2");
|
||||
f.f.tx.executeWithoutResult(s->f.photos.importIfEmpty(id,"operator",Slot.LOADING_SCENE,List.of(image)));
|
||||
verify(f.files,times(2)).upload(anyLong(),any(),anyString(),any());
|
||||
assertThat(f.photos.read(id).recordVersion()).isEqualTo("2");
|
||||
f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete-imported")));
|
||||
f.bind(2,f.f.actor);f.bind(3,f.f.actor);
|
||||
assertThat(f.bind(4,new Actor("picker",ActorMode.PDA,"9")).stage()).isEqualTo("DELIVERING");
|
||||
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());
|
||||
}
|
||||
/** 每例恢复隔离身份,不影响其他服务权限测试。 */
|
||||
@AfterEach void clearSecurity(){SecurityContextHolder.clearContext();}
|
||||
|
||||
@@ -221,11 +283,15 @@ class DispatchLoadingDeparturePersistenceTest {
|
||||
final DispatchPdaWorkflowService sourceWorkflow=mock(DispatchPdaWorkflowService.class);
|
||||
final DispatchLoadingDepartureService service;final DispatchLoadingDocumentService documents;final String number;final long version;final String manifest;
|
||||
final List<LoadingMeasurement> measurements;
|
||||
final OwnedBusinessFileGateway files=mock(OwnedBusinessFileGateway.class);
|
||||
final DispatchLoadingPhotoAdapter photos;
|
||||
/** 创建两客户货物并走真实两级审核、真实拣货归属,不伪造已装车状态。 */
|
||||
Fixture() throws Exception {
|
||||
Fixture() throws Exception { this(true); }
|
||||
/** 零扫码夹具用于退回重审;其余用例继续真实拣货。 */
|
||||
Fixture(boolean picked) throws Exception {
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("create",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"202",502,"LAX9",5)))));
|
||||
number=created.dispatchNo();version=f.approve(created).recordVersion();
|
||||
f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8");
|
||||
if(picked) {f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8");}
|
||||
f.db.execute("ALTER TABLE ll_order ADD detained INT DEFAULT 0");
|
||||
var preview=f.tx.execute(s->f.service.loading(f.actor,number));manifest=preview.manifestHash();
|
||||
measurements=preview.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,BigDecimal.ONE,BigDecimal.ONE,p.deliveryIndex())).toList();
|
||||
@@ -259,9 +325,9 @@ class DispatchLoadingDeparturePersistenceTest {
|
||||
f.db.execute(matcher.group().replace(" JSON"," LONGTEXT").replace(" COLLATE utf8mb4_bin","").replaceFirst("(?s) ENGINE=InnoDB.*?;$",";"));
|
||||
}
|
||||
var records=new OwnedBusinessRepository(f.session.getMapper(OwnedBusinessMapper.class),mock(OwnedBusinessQueryMapper.class));
|
||||
var codec=new OwnedBusinessSupport(f.json);var files=mock(OwnedBusinessFileGateway.class);
|
||||
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"));
|
||||
var photos=new DispatchLoadingPhotoAdapter(records,new OwnedBusinessCommandService(records,codec),new OwnedBusinessProjection(codec),codec,files);
|
||||
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);
|
||||
}
|
||||
|
||||
+50
@@ -14,6 +14,56 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
/** 自动带入不复用跨排车文件ID,也不覆盖已有当趟照片。 */
|
||||
class DispatchMasterPhotoServiceTest {
|
||||
/** 只失效来源变化的类别;相同来源重审、无关资料修改和文件重排保留现场替换。 */
|
||||
@Test void resourceChangesInvalidateOnlyAffectedCategories() {
|
||||
var loading=mock(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.class);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"loadingPhotos",loading);
|
||||
var vehicle=new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST",null,"IN_SERVICE",null,List.of(101L,102L));
|
||||
var driver=new DriverSnapshot(ResourceMode.MASTER,2L,"司机","电话",new DriverEvidence("LICENSE",null,List.of(8L),null,List.of(90L),8L));
|
||||
var original=new Resources(vehicle,driver,null);
|
||||
service.invalidateLoading(9,"op",original,new Resources(new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST","新承运商","IN_SERVICE",new Capacity(10,null,null),List.of(102L,101L)),
|
||||
new DriverSnapshot(ResourceMode.MASTER,2L,"司机","新电话",new DriverEvidence("LICENSE",null,List.of(8L),java.time.Instant.now(),List.of(91L),8L)),null));
|
||||
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));
|
||||
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));
|
||||
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));
|
||||
}
|
||||
@Test void importsFrozenLicenseAndVehicleIntoSharedLoadingCategoriesWithoutDriverPortrait() throws Exception {
|
||||
var loading=mock(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.class);
|
||||
var fleet=mock(com.baiyue.lastleg.fleet.trip.FleetTripExecutionService.class);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"loadingPhotos",loading);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"fleet",fleet);
|
||||
when(loading.read(9)).thenReturn(new com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.State("0",List.of()));
|
||||
var license=new com.baiyue.lastleg.fleet.driver.DriverModels.File("8","license.pdf","application/pdf",8);
|
||||
when(fleet.dispatchDriverLicense(2,8)).thenReturn(new com.baiyue.lastleg.fleet.driver.DriverModels.Download(license,"%PDF-1.4".getBytes()));
|
||||
when(evidence.metadata(9,DispatchEvidenceGateway.Kind.VEHICLE,101)).thenReturn(new DispatchEvidenceGateway.File(101L,"plate.png","image/png",3));
|
||||
when(evidence.download(9,DispatchEvidenceGateway.Kind.VEHICLE,101)).thenReturn(new byte[]{1,2,3});
|
||||
var resources=new Resources(new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST",null,"IN_SERVICE",null,List.of(101L)),
|
||||
new DriverSnapshot(ResourceMode.MASTER,2L,"司机","电话",new DriverEvidence(null,null,List.of(8L),null,List.of(102L),8L)),null);
|
||||
service.populateLoading(9,"op",resources);
|
||||
@SuppressWarnings("unchecked") var files=(ArgumentCaptor<List<MultipartFile>>)(ArgumentCaptor<?>)ArgumentCaptor.forClass(List.class);
|
||||
verify(loading).importIfEmpty(eq(9L),eq("op"),eq(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_SCENE),files.capture());
|
||||
assertThat(files.getValue()).hasSize(1);assertThat(files.getValue().get(0).getBytes()).isEqualTo("%PDF-1.4".getBytes());
|
||||
verify(loading).importIfEmpty(eq(9L),eq("op"),eq(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_LIST),files.capture());
|
||||
assertThat(files.getValue().get(0).getOriginalFilename()).isEqualTo("plate.png");
|
||||
verify(evidence,never()).download(anyLong(),eq(DispatchEvidenceGateway.Kind.DRIVER_PHOTO),anyLong());
|
||||
verifyNoInteractions(photos);
|
||||
var slots=com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.values();
|
||||
var saved=java.util.Arrays.stream(slots).map(slot->new com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Photo(slot,"LOADING","900"+slot.ordinal(),"saved.png","image/png","3",null,null)).toList();
|
||||
when(loading.read(9)).thenReturn(new com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.State("5",saved));
|
||||
clearInvocations(loading,fleet,evidence);
|
||||
service.populateLoading(9,"op",resources);
|
||||
verify(loading,never()).importIfEmpty(anyLong(),anyString(),any(),anyList());
|
||||
verifyNoInteractions(fleet,evidence);
|
||||
}
|
||||
final FleetResourcePhotoService photos=mock(FleetResourcePhotoService.class);
|
||||
final DispatchEvidenceGateway evidence=mock(DispatchEvidenceGateway.class);
|
||||
final DispatchMasterPhotoService service=new DispatchMasterPhotoService(photos,evidence);
|
||||
|
||||
+17
@@ -11,6 +11,23 @@ import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** 审核两端共用真实持久主单,驳回回客服且不能复用上一轮批准。 */
|
||||
class DispatchReviewWorkflowPersistenceTest {
|
||||
@Test void approvedLoadingReviewImportsOnceAndCopyFailureRollsBackApproval() throws Exception {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("copy-create",List.of(f.source(1,"101",501,"ONT8",3)))));
|
||||
var submitted=submit(f,created,"copy-submit");
|
||||
var approved=review(f,f.actor,submitted,false,"copy-dispatch",Decision.APPROVED,null);
|
||||
var master=org.mockito.Mockito.mock(DispatchMasterPhotoService.class);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"masterPhotos",master);
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("文件服务不可用")).doNothing().when(master).populateLoading(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any());
|
||||
assertThatThrownBy(()->review(f,f.actor,approved,true,"copy-loading",Decision.APPROVED,null)).hasMessageContaining("文件服务不可用");
|
||||
assertThat(f.repository.find(created.dispatchNo()).getStage()).isEqualTo("BEFORE_LOADING");
|
||||
assertThat(f.repository.find(created.dispatchNo()).getReviewState()).isEqualTo("LOADING_PENDING");
|
||||
var loaded=review(f,f.actor,approved,true,"copy-loading",Decision.APPROVED,null);
|
||||
assertThat(loaded.stage()).isEqualTo(Stage.LOADING);
|
||||
org.mockito.Mockito.clearInvocations(master);
|
||||
assertThat(review(f,f.actor,approved,true,"copy-loading",Decision.APPROVED,null).recordVersion()).isEqualTo(loaded.recordVersion());
|
||||
org.mockito.Mockito.verifyNoInteractions(master);
|
||||
}
|
||||
/** 取货复用同轮两级审核与驳回重提,不创建仓内拣货任务。 */
|
||||
@Test void pickupUsesBothReviewsAndRejectsSkippingOrStaleApproval() throws Exception {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
|
||||
+12
-3
@@ -11,9 +11,18 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
class OwnedBusinessFileGatewayTest {
|
||||
/** 图片文本伪装、PDF及空内容均须在File调用前拒绝。 */
|
||||
@Test void invalidImageSignaturesAndMimeAreRejected() {
|
||||
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.png","image/png","not-image".getBytes())));
|
||||
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.pdf","application/pdf","%PDF".getBytes())));
|
||||
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.jpg","image/jpeg",new byte[0])));
|
||||
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(PhotoSlot.LOADING_SCENE,new OwnedBusinessFileGateway.Upload("x.png","image/png","not-image".getBytes())));
|
||||
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(PhotoSlot.LOADING_SCENE,new OwnedBusinessFileGateway.Upload("x.pdf","application/pdf","%PDF".getBytes())));
|
||||
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(PhotoSlot.LOADING_SCENE,new OwnedBusinessFileGateway.Upload("x.jpg","image/jpeg",new byte[0])));
|
||||
}
|
||||
@Test void onlyDriverLicenseAcceptsPdfAndChecksMetadataScope() {
|
||||
var pdf=new OwnedBusinessFileGateway.Upload("license.pdf","application/pdf","%PDF-1.4".getBytes());
|
||||
assertDoesNotThrow(()->HttpOwnedBusinessFileGateway.validate(PhotoSlot.LOADING_SCENE,pdf));
|
||||
for(var slot:PhotoSlot.values())if(slot!=PhotoSlot.LOADING_SCENE)
|
||||
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(slot,pdf));
|
||||
var meta=new HttpOwnedBusinessFileGateway.Metadata(9L,0L,"OVERSEAS_DISPATCH_LOADING_PHOTO","1","license.pdf","application/pdf",8L,LocalDateTime.now());
|
||||
assertEquals(9,HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",meta),1,PhotoSlot.LOADING_SCENE,9L).fileId());
|
||||
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",meta),1,PhotoSlot.LOADING_LIST,9L));
|
||||
}
|
||||
/** 同一原dispatch内部ID、平台0与分类都匹配才接受元数据。 */
|
||||
@Test void metadataCannotCrossCustomerDispatchOrCategory() {
|
||||
|
||||
Reference in New Issue
Block a user