优化
This commit is contained in:
+14
-14
@@ -9,7 +9,7 @@ import java.util.List;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
|
||||
/** 两端装车完成即发车:所有中间状态只存在于同一事务,原请求只恢复最终结果。 */
|
||||
/** 装车完成停在装车后;资料齐全时由资料服务在保存事务中推进派送。 */
|
||||
@Service
|
||||
public class DispatchLoadingDepartureService {
|
||||
private final DispatchAggregateService workflow;
|
||||
@@ -24,31 +24,31 @@ public class DispatchLoadingDepartureService {
|
||||
this.workflow=workflow;this.delivery=delivery;this.departure=departure;this.commands=commands;this.hashes=hashes;
|
||||
}
|
||||
|
||||
/** 原键恢复先于阶段校验;任一步失败均回滚装载、子单、费用、发车与全部账本。 */
|
||||
/** 两端装车完成只保存真实实载,原请求精确恢复装车后结果。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public CommandResult complete(Actor actor,String number,DispatchCommands.LoadingComplete request) {
|
||||
workflow.requireLoadingPermission(actor);
|
||||
return workflow.loadingComplete(actor,number,request);
|
||||
}
|
||||
|
||||
/** 仅供装车资料齐全的保存事务调用;派送准备和发车失败回滚最后一张绑定。 */
|
||||
@Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED)
|
||||
public CommandResult departAfterDocuments(Actor actor,String number,String key) {
|
||||
var context=workflow.lockForArrangement(actor,number);
|
||||
var identity=new CommandIdentity(Action.LOADING_COMPLETE,request.idempotencyKey(),request.recordVersion(),
|
||||
hashes.command(actor,Action.LOADING_COMPLETE,number,request.recordVersion(),request));
|
||||
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
|
||||
if(context.stage()!=Stage.AFTER_LOADING || context.status()!=Status.ACTIVE)
|
||||
throw new BizException(ResultCode.CONFLICT,"当前阶段不能按装车资料推进派送");
|
||||
// 先取得本车单据锁再申请真实来源锁,后续所有步骤保持同一个整车父锁。
|
||||
delivery.departureFacts(context,true);
|
||||
var loaded=workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(),
|
||||
stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.measurements(),request.remark()));
|
||||
context=workflow.preview(actor,number);
|
||||
String arrangementKey=stepKey(number,request.idempotencyKey(),"ARRANGE");
|
||||
String arrangementKey=stepKey(number,key,"ARRANGE");
|
||||
var fact=delivery.prepareForDeparture(context,actor,arrangementKey);
|
||||
var arrangementIdentity=new CommandIdentity(Action.ARRANGE_DELIVERY,arrangementKey,context.recordVersion(),
|
||||
hashes.source(List.of(number,actor.operatorId(),context.recordVersion(),fact.arrangementBusinessHash())));
|
||||
var arranged=workflow.recordArrangement(context,actor,arrangementIdentity,fact,false);
|
||||
var departed=departure.depart(actor,number,new DispatchCommands.Depart(arranged.recordVersion(),
|
||||
stepKey(number,request.idempotencyKey(),"DEPART"),request.remark()));
|
||||
if(departed.stage()!=Stage.DELIVERING || !(loaded.result() instanceof DispatchResults.Loaded))
|
||||
stepKey(number,key,"DEPART"),"五张装车照片已齐全,自动进入派送中"));
|
||||
if(departed.stage()!=Stage.DELIVERING)
|
||||
throw new BizException(ResultCode.CONFLICT,"装车发运结果未核平");
|
||||
var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),departed.recordVersion(),
|
||||
departed.status(),departed.stage(),departed.occurredAt(),Effect.APPLIED,departed.counts(),loaded.result(),false,false);
|
||||
commands.append(context.dispatchId(),actor,identity,result);return result;
|
||||
return departed;
|
||||
}
|
||||
|
||||
/** 固定长度内部键绑定原请求和步骤,不向客户端暴露中间命令作为恢复入口。 */
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.DispatchLoadingDocumentService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/** 平台排车装车资料入口,与另一端共用五框和自动派送门禁。 */
|
||||
@RestController
|
||||
@Validated
|
||||
@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents")
|
||||
public class DispatchLoadingDocumentController {
|
||||
private final DispatchLoadingDocumentService service;
|
||||
/** Controller只负责会话与输入映射,所有资料和状态规则归服务。 */
|
||||
public DispatchLoadingDocumentController(DispatchLoadingDocumentService service){this.service=service;}
|
||||
/** 读取本车已保存照片,不以设备缓存作为上传事实。 */
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
public ApiResponse<DispatchLoadingDocumentService.Documents> get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
|
||||
return ApiResponse.success(service.get(DispatchActorFactory.platform(user),dispatchNo));
|
||||
}
|
||||
/** 上传先记录原请求,成功回执后再保存本车槽位引用。 */
|
||||
@PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
|
||||
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
|
||||
return ApiResponse.success(service.upload(DispatchActorFactory.platform(user),dispatchNo,slot,idempotencyKey,file));
|
||||
}
|
||||
/** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */
|
||||
@PutMapping("/{slot}")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<DispatchLoadingDocumentService.Documents> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
|
||||
@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) {
|
||||
return ApiResponse.success(service.bind(DispatchActorFactory.platform(user),dispatchNo,slot,request));
|
||||
}
|
||||
/** 原上传仅允许当前有权限的原操作者恢复。 */
|
||||
@GetMapping("/uploads/{idempotencyKey}")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<Photo> recover(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
|
||||
return ApiResponse.success(service.recover(DispatchActorFactory.platform(user),dispatchNo,idempotencyKey));
|
||||
}
|
||||
/** 每次预览核对本车当前绑定,文件不得跨车跨仓读取。 */
|
||||
@GetMapping("/{slot}/files/{fileId}/content")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@PathVariable @Positive long fileId) {
|
||||
var value=service.content(DispatchActorFactory.platform(user),dispatchNo,slot,fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(value.photo().contentType()))
|
||||
.header("X-Content-Type-Options","nosniff").body(value.content());
|
||||
}
|
||||
}
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.DispatchLoadingDocumentService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/** 绑定仓PDA装车资料入口,与另一端共用五框和自动派送门禁。 */
|
||||
@RestController
|
||||
@Validated
|
||||
@RequestMapping("/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents")
|
||||
public class PdaDispatchLoadingDocumentController {
|
||||
private final DispatchLoadingDocumentService service;
|
||||
/** Controller只负责会话与输入映射,所有资料和状态规则归服务。 */
|
||||
public PdaDispatchLoadingDocumentController(DispatchLoadingDocumentService service){this.service=service;}
|
||||
/** 读取本车已保存照片,不以设备缓存作为上传事实。 */
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
|
||||
public ApiResponse<DispatchLoadingDocumentService.Documents> get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
|
||||
return ApiResponse.success(service.get(DispatchActorFactory.pda(user),dispatchNo));
|
||||
}
|
||||
/** 上传先记录原请求,成功回执后再保存本车槽位引用。 */
|
||||
@PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
|
||||
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
|
||||
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
|
||||
return ApiResponse.success(service.upload(DispatchActorFactory.pda(user),dispatchNo,slot,idempotencyKey,file));
|
||||
}
|
||||
/** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */
|
||||
@PutMapping("/{slot}")
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
|
||||
public ApiResponse<DispatchLoadingDocumentService.Documents> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
|
||||
@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) {
|
||||
return ApiResponse.success(service.bind(DispatchActorFactory.pda(user),dispatchNo,slot,request));
|
||||
}
|
||||
/** 原上传仅允许当前有权限的原操作者恢复。 */
|
||||
@GetMapping("/uploads/{idempotencyKey}")
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
|
||||
public ApiResponse<Photo> recover(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
|
||||
return ApiResponse.success(service.recover(DispatchActorFactory.pda(user),dispatchNo,idempotencyKey));
|
||||
}
|
||||
/** 每次预览核对本车当前绑定,文件不得跨车跨仓读取。 */
|
||||
@GetMapping("/{slot}/files/{fileId}/content")
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
|
||||
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@PathVariable @Positive long fileId) {
|
||||
var value=service.content(DispatchActorFactory.pda(user),dispatchNo,slot,fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(value.photo().contentType()))
|
||||
.header("X-Content-Type-Options","nosniff").body(value.content());
|
||||
}
|
||||
}
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Context;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchTypes.Stage;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
|
||||
|
||||
/** Admin与绑定仓PDA共用原整车五框照片;不复制派送单或文件引用。 */
|
||||
@Service
|
||||
public class DispatchLoadingDocumentService {
|
||||
public static final List<PhotoSlot> SLOTS=List.of(PhotoSlot.LOADING_SCENE,PhotoSlot.LOADING_LIST,
|
||||
PhotoSlot.PRE_DEPARTURE_CHECK,PhotoSlot.DRIVER_LICENSE,PhotoSlot.RECEIPT);
|
||||
private final DispatchAggregateService workflow;
|
||||
private final DispatchLoadingDepartureService departure;
|
||||
private final OwnedBusinessRepository repository;
|
||||
private final OwnedBusinessCommandService commands;
|
||||
private final OwnedBusinessProjection projection;
|
||||
private final OwnedBusinessSupport codec;
|
||||
private final OwnedBusinessFileGateway files;
|
||||
/** 复用现有附加表、文件归属校验和上传意图账本。 */
|
||||
public DispatchLoadingDocumentService(DispatchAggregateService workflow,DispatchLoadingDepartureService departure,
|
||||
OwnedBusinessRepository repository,OwnedBusinessCommandService commands,OwnedBusinessProjection projection,
|
||||
OwnedBusinessSupport codec,OwnedBusinessFileGateway files) {
|
||||
this.workflow=workflow;this.departure=departure;this.repository=repository;this.commands=commands;
|
||||
this.projection=projection;this.codec=codec;this.files=files;
|
||||
}
|
||||
public record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,
|
||||
String stage,boolean editable,boolean complete,List<Photo> photos) {}
|
||||
public record Binding(PhotoSlot slot,BindPhoto request) {}
|
||||
public record Download(Photo photo,byte[] content) {}
|
||||
|
||||
/** GET不写入照片或推进阶段;PDA每次重验冻结仓。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public Documents get(Actor actor,String number) {
|
||||
require(actor,false);var context=context(actor,number,false);
|
||||
return documents(context,repository.record(context.dispatchId()),canWrite(actor));
|
||||
}
|
||||
|
||||
/** 先落稳定上传意图,再调用文件服务,未知结果不能换键重传。 */
|
||||
public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart) {
|
||||
require(actor,true);slot(slot);codec.key(key);var context=context(actor,number,false);
|
||||
if(multipart==null || multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)
|
||||
throw bad("照片须为非空JPG/PNG,最大10MiB");
|
||||
byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException failure){throw bad("无法读取待上传照片");}
|
||||
var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes);
|
||||
HttpOwnedBusinessFileGateway.validate(payload);
|
||||
var identity=new OwnedBusinessPhotoService.UploadIdentity(slot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes));
|
||||
var reservation=commands.reserveUpload(context.dispatchId(),operator(actor),key,identity);
|
||||
if(!reservation.created())return uploaded(reservation.command());
|
||||
try {
|
||||
var result=photo(slot,files.upload(context.dispatchId(),slot,actor.operatorId(),payload));
|
||||
commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return result;
|
||||
} catch(RuntimeException failure) {
|
||||
try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,"DISPATCH_LOADING_UPLOAD_UNKNOWN");}
|
||||
catch(RuntimeException recording){failure.addSuppressed(recording);}
|
||||
throw conflict("照片上传结果未知,请保留原请求键核对");
|
||||
}
|
||||
}
|
||||
|
||||
/** 原上传恢复只允许原操作者和本车装车槽位。 */
|
||||
public Photo recover(Actor actor,String number,String key) {
|
||||
require(actor,true);codec.key(key);var context=context(actor,number,false);
|
||||
var command=commands.existing(context.dispatchId(),key,operator(actor));
|
||||
if(command==null)throw new BizException(ResultCode.NOT_FOUND,"未找到本车原上传请求");
|
||||
return uploaded(command);
|
||||
}
|
||||
|
||||
/** 锁顺序为车次、排车、附加行;第五张绑定与派送发车同事务成功或回滚。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public Documents bind(Actor actor,String number,PhotoSlot slot,BindPhoto request) {
|
||||
require(actor,true);slot(slot);if(request==null)throw bad("照片绑定请求缺失");
|
||||
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion()),fileId=codec.version(request.fileId());
|
||||
if(fileId<=0)throw bad("文件编号无效");
|
||||
var context=context(actor,number,true);var operator=operator(actor);var identity=new Binding(slot,request);
|
||||
var old=commands.existing(context.dispatchId(),request.idempotencyKey(),operator);
|
||||
if(old!=null) {
|
||||
commands.same(old,"BIND_LOADING_DOCUMENT",version,identity);
|
||||
if(!"SUCCEEDED".equals(old.getStatus()))throw conflict("原资料保存结果尚未确认");
|
||||
return codec.read(old.getResultJson(),Documents.class);
|
||||
}
|
||||
// 文件必须属于本排车与指定槽位,不能拿其他车或签收资料充数。
|
||||
var verified=photo(slot,files.metadata(context.dispatchId(),slot,fileId));
|
||||
var seed=new OwnedBusinessRows.Record();seed.setId(IdWorker.getId());seed.setDispatchId(context.dispatchId());
|
||||
seed.setCreatedBy(actor.operatorId());seed.setUpdatedBy(actor.operatorId());seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt());
|
||||
var row=repository.lock(seed);
|
||||
if(row==null || row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("装车资料已被其他操作更新,请刷新后核对");
|
||||
var photos=new EnumMap<PhotoSlot,Photo>(PhotoSlot.class);
|
||||
for(var value:projection.photos(row))photos.put(value.slot(),value);
|
||||
photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()));
|
||||
row.setPhotosJson(codec.write(List.copyOf(photos.values())));row.setRecordVersion(Math.addExact(version,1));
|
||||
row.setUpdatedBy(actor.operatorId());row.setUpdatedAt(codec.utc(Instant.now()));
|
||||
if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败");
|
||||
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(context.dispatchId());
|
||||
history.setRecordVersion(row.getRecordVersion());history.setAction("BIND_LOADING_DOCUMENT");history.setOperatorId(actor.operatorId());
|
||||
history.setReason("保存装车照片 "+(SLOTS.indexOf(slot)+1));history.setOccurredAt(row.getUpdatedAt());
|
||||
history.setSnapshotJson(codec.write(new Snapshot(projection.operations(row),projection.photos(row),actor.operatorId())));repository.history(history);
|
||||
if(context.stage()==Stage.AFTER_LOADING && complete(List.copyOf(photos.values()))) {
|
||||
departure.departAfterDocuments(actor,number,request.idempotencyKey());
|
||||
context=workflow.preview(actor,number);
|
||||
}
|
||||
var result=documents(context,row,true);
|
||||
commands.success(context.dispatchId(),operator,request.idempotencyKey(),"BIND_LOADING_DOCUMENT",version,identity,result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 预览必须引用当前本车已绑定文件,下载不会暴露未保存上传。 */
|
||||
@Transactional(readOnly=true)
|
||||
public Download content(Actor actor,String number,PhotoSlot slot,long fileId) {
|
||||
require(actor,false);slot(slot);var context=context(actor,number,false);
|
||||
var photo=projection.photos(repository.record(context.dispatchId())).stream()
|
||||
.filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst()
|
||||
.orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车未绑定该装车照片"));
|
||||
return new Download(photo,files.content(context.dispatchId(),slot,fileId));
|
||||
}
|
||||
|
||||
/** 阶段与真实装载证据均须存在,PDA仓隔离由主流程统一复核。 */
|
||||
private Context context(Actor actor,String number,boolean lock) {
|
||||
var value=lock?workflow.lockForArrangement(actor,number):workflow.preview(actor,number);
|
||||
if(value.status()!=Status.ACTIVE || !Set.of(Stage.AFTER_LOADING,Stage.DELIVERING,Stage.DELIVERED).contains(value.stage())
|
||||
|| value.loadingCompletedAt()==null || value.loadingCompletionHash()==null)throw conflict("装车完成后才能管理装车资料");
|
||||
return value;
|
||||
}
|
||||
/** 返回当前共享附加版本,五个固定框各有一份已保存图片才算齐全。 */
|
||||
private Documents documents(Context context,OwnedBusinessRows.Record row,boolean editable) {
|
||||
var photos=projection.photos(row).stream().filter(p->SLOTS.contains(p.slot())).toList();
|
||||
return new Documents(context.dispatchNo(),row==null?"0":row.getRecordVersion().toString(),Long.toString(context.recordVersion()),
|
||||
context.stage().name(),editable,complete(photos),photos);
|
||||
}
|
||||
/** 不按总张数判断,重复槽位和空文件引用不能满足五框。 */
|
||||
static boolean complete(List<Photo> photos) {
|
||||
return SLOTS.stream().allMatch(slot->photos.stream().anyMatch(p->p.slot()==slot && p.fileId()!=null && !p.fileId().isBlank()));
|
||||
}
|
||||
/** 平台读写权限分离,PDA沿既有仓作业身份授权。 */
|
||||
private void require(Actor actor,boolean write) {
|
||||
if(actor==null || actor.operatorId()==null || !allowed(actor,write))throw new BizException(ResultCode.FORBIDDEN,"无装车资料操作权限");
|
||||
}
|
||||
/** 上传按钮只反映当前主体的实际写权限。 */
|
||||
private boolean canWrite(Actor actor){return allowed(actor,true);}
|
||||
/** 权限直接读取当前会话,不给PDA补造平台权限。 */
|
||||
private boolean allowed(Actor actor,boolean write) {
|
||||
var auth=SecurityContextHolder.getContext().getAuthentication();if(auth==null || actor==null)return false;
|
||||
var authorities=auth.getAuthorities().stream().map(a->a.getAuthority()).toList();
|
||||
return actor.mode()==ActorMode.PDA?actor.boundWarehouseId()!=null && authorities.contains("lastleg:pda:operate")
|
||||
&& authorities.contains("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")
|
||||
:authorities.contains("platform:overseas-warehouse:dispatch:"+(write?"edit":"list"));
|
||||
}
|
||||
/** 账本只消费可信操作者,权限由本服务独立校验。 */
|
||||
private OwnedBusinessModels.Actor operator(Actor actor){return new OwnedBusinessModels.Actor(actor.operatorId(),actor.operatorId(),Set.of());}
|
||||
/** 油耗照片不能替代装车五框。 */
|
||||
private void slot(PhotoSlot slot){if(!SLOTS.contains(slot))throw bad("请选择有效的装车照片框");}
|
||||
/** 恢复回执仍须属于上传动作及装车五框。 */
|
||||
private Photo uploaded(OwnedBusinessRows.Command command) {
|
||||
if(!"UPLOAD_PHOTO".equals(command.getAction()) || !"SUCCEEDED".equals(command.getStatus()) || command.getResultJson()==null)
|
||||
throw conflict("原上传结果尚未确认,请保留原请求");
|
||||
var result=codec.read(command.getResultJson(),Photo.class);slot(result.slot());return result;
|
||||
}
|
||||
/** 上传回执不标记已绑定,实际保存时间只在绑定事务填写。 */
|
||||
private Photo photo(PhotoSlot slot,OwnedBusinessFileGateway.Evidence value) {
|
||||
return new Photo(slot,slot.category(),Long.toString(value.fileId()),value.fileName(),value.contentType(),Long.toString(value.sizeBytes()),value.uploadedAt(),null);
|
||||
}
|
||||
}
|
||||
@@ -1,379 +1,242 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "排车车辆资料",
|
||||
"version": "1.0.0",
|
||||
"description": "仅平台有效排车DELIVERING/DELIVERED可用;兼容有无拼车货物,沿装车归属证据校验。共享原车队照片、版本、命令与历史,不新建附件存储。派送文件仍使用delivery-orders documents;不把车队RECEIPT照片当签收。列表权限只读,排车edit独立写;恢复还需list。"
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents": {
|
||||
"get": {
|
||||
"operationId": "getDispatchVehicleDocuments",
|
||||
"summary": "读取本车油耗图片;复用车队附加记录",
|
||||
"x-permission-code": "platform:overseas-warehouse:dispatch:list",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/dispatchNo"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Documents"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/uploads": {
|
||||
"post": {
|
||||
"operationId": "uploadDispatchVehiclePhoto",
|
||||
"summary": "准备本车油耗图片;尚未绑定保存",
|
||||
"x-permission-code": "platform:overseas-warehouse:dispatch:edit",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/dispatchNo"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/slot"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Photo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"file",
|
||||
"idempotencyKey"
|
||||
],
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string",
|
||||
"format": "binary",
|
||||
"description": "非空JPG/PNG,最大10MiB"
|
||||
},
|
||||
"idempotencyKey": {
|
||||
"type": "string",
|
||||
"minLength": 8,
|
||||
"maxLength": 128
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}": {
|
||||
"put": {
|
||||
"operationId": "bindDispatchVehiclePhoto",
|
||||
"summary": "按附加版本绑定原上传文件,不改变排车或签收状态",
|
||||
"x-permission-code": "platform:overseas-warehouse:dispatch:edit",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/dispatchNo"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/slot"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Documents"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BindPhoto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/files/{fileId}/content": {
|
||||
"get": {
|
||||
"operationId": "readDispatchVehiclePhoto",
|
||||
"summary": "只读取本车当前绑定图片",
|
||||
"x-permission-code": "platform:overseas-warehouse:dispatch:list",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/dispatchNo"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/slot"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/fileId"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "no-store图片内容",
|
||||
"content": {
|
||||
"image/jpeg": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
},
|
||||
"image/png": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/uploads/{idempotencyKey}": {
|
||||
"get": {
|
||||
"operationId": "recoverDispatchVehiclePhoto",
|
||||
"summary": "只恢复当前操作者原油耗上传成功结果,未知结果拒绝重传",
|
||||
"x-permission-code": "platform:overseas-warehouse:dispatch:edit",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/dispatchNo"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/idempotencyKey"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Photo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT"
|
||||
}
|
||||
},
|
||||
"parameters": {
|
||||
"dispatchNo": {
|
||||
"name": "dispatchNo",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"slot": {
|
||||
"name": "slot",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"FUEL_START",
|
||||
"FUEL_END"
|
||||
]
|
||||
}
|
||||
},
|
||||
"fileId": {
|
||||
"name": "fileId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"idempotencyKey": {
|
||||
"name": "idempotencyKey",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"Photo": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"slot",
|
||||
"fileId",
|
||||
"fileName",
|
||||
"contentType",
|
||||
"sizeBytes",
|
||||
"uploadedAt"
|
||||
],
|
||||
"properties": {
|
||||
"slot": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"FUEL_START",
|
||||
"FUEL_END"
|
||||
]
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"FUEL"
|
||||
]
|
||||
},
|
||||
"fileId": {
|
||||
"type": "string"
|
||||
},
|
||||
"fileName": {
|
||||
"type": "string"
|
||||
},
|
||||
"contentType": {
|
||||
"type": "string"
|
||||
},
|
||||
"sizeBytes": {
|
||||
"type": "string"
|
||||
},
|
||||
"uploadedAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"savedAt": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"Documents": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"dispatchNo",
|
||||
"recordVersion",
|
||||
"editable",
|
||||
"photos"
|
||||
],
|
||||
"properties": {
|
||||
"dispatchNo": {
|
||||
"type": "string"
|
||||
},
|
||||
"recordVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"editable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"photos": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Photo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"BindPhoto": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"recordVersion",
|
||||
"idempotencyKey",
|
||||
"fileId"
|
||||
],
|
||||
"properties": {
|
||||
"recordVersion": {
|
||||
"type": "string",
|
||||
"pattern": "^(0|[1-9][0-9]*)$"
|
||||
},
|
||||
"idempotencyKey": {
|
||||
"type": "string",
|
||||
"minLength": 8,
|
||||
"maxLength": 128
|
||||
},
|
||||
"fileId": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 排车车辆资料
|
||||
version: 1.0.0
|
||||
description: 仅平台有效排车DELIVERING/DELIVERED可用;兼容有无拼车货物,沿装车归属证据校验。共享原车队照片、版本、命令与历史,不新建附件存储。派送文件仍使用delivery-orders
|
||||
documents;不把车队RECEIPT照片当签收。列表权限只读,排车edit独立写;恢复还需list。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents:
|
||||
get:
|
||||
operationId: getDispatchVehicleDocuments
|
||||
summary: 读取本车油耗图片;复用车队附加记录
|
||||
x-permission-code: platform:overseas-warehouse:dispatch:list
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/dispatchNo'
|
||||
responses:
|
||||
'200':
|
||||
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/Documents'
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/uploads:
|
||||
post:
|
||||
operationId: uploadDispatchVehiclePhoto
|
||||
summary: 准备本车油耗图片;尚未绑定保存
|
||||
x-permission-code: platform:overseas-warehouse:dispatch:edit
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/dispatchNo'
|
||||
- $ref: '#/components/parameters/slot'
|
||||
responses:
|
||||
'200':
|
||||
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/Photo'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- file
|
||||
- idempotencyKey
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
description: 非空JPG/PNG,最大10MiB
|
||||
idempotencyKey:
|
||||
type: string
|
||||
minLength: 8
|
||||
maxLength: 128
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}:
|
||||
put:
|
||||
operationId: bindDispatchVehiclePhoto
|
||||
summary: 按附加版本绑定原上传文件,不改变排车或签收状态
|
||||
x-permission-code: platform:overseas-warehouse:dispatch:edit
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/dispatchNo'
|
||||
- $ref: '#/components/parameters/slot'
|
||||
responses:
|
||||
'200':
|
||||
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/Documents'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BindPhoto'
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/files/{fileId}/content:
|
||||
get:
|
||||
operationId: readDispatchVehiclePhoto
|
||||
summary: 只读取本车当前绑定图片
|
||||
x-permission-code: platform:overseas-warehouse:dispatch:list
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/dispatchNo'
|
||||
- $ref: '#/components/parameters/slot'
|
||||
- $ref: '#/components/parameters/fileId'
|
||||
responses:
|
||||
'200':
|
||||
description: no-store图片内容
|
||||
content:
|
||||
image/jpeg:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
image/png:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/uploads/{idempotencyKey}:
|
||||
get:
|
||||
operationId: recoverDispatchVehiclePhoto
|
||||
summary: 只恢复当前操作者原油耗上传成功结果,未知结果拒绝重传
|
||||
x-permission-code: platform:overseas-warehouse:dispatch:edit
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/dispatchNo'
|
||||
- $ref: '#/components/parameters/idempotencyKey'
|
||||
responses:
|
||||
'200':
|
||||
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/Photo'
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
parameters:
|
||||
dispatchNo:
|
||||
name: dispatchNo
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
slot:
|
||||
name: slot
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- FUEL_START
|
||||
- FUEL_END
|
||||
fileId:
|
||||
name: fileId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
idempotencyKey:
|
||||
name: idempotencyKey
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
schemas:
|
||||
Photo:
|
||||
type: object
|
||||
required:
|
||||
- slot
|
||||
- fileId
|
||||
- fileName
|
||||
- contentType
|
||||
- sizeBytes
|
||||
- uploadedAt
|
||||
properties:
|
||||
slot:
|
||||
type: string
|
||||
enum:
|
||||
- FUEL_START
|
||||
- FUEL_END
|
||||
category:
|
||||
type: string
|
||||
enum:
|
||||
- FUEL
|
||||
fileId:
|
||||
type: string
|
||||
fileName:
|
||||
type: string
|
||||
contentType:
|
||||
type: string
|
||||
sizeBytes:
|
||||
type: string
|
||||
uploadedAt:
|
||||
type: string
|
||||
savedAt:
|
||||
type: string
|
||||
nullable: true
|
||||
Documents:
|
||||
type: object
|
||||
required:
|
||||
- dispatchNo
|
||||
- recordVersion
|
||||
- editable
|
||||
- photos
|
||||
properties:
|
||||
dispatchNo:
|
||||
type: string
|
||||
recordVersion:
|
||||
type: string
|
||||
editable:
|
||||
type: boolean
|
||||
photos:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Photo'
|
||||
BindPhoto:
|
||||
type: object
|
||||
required:
|
||||
- recordVersion
|
||||
- idempotencyKey
|
||||
- fileId
|
||||
properties:
|
||||
recordVersion:
|
||||
type: string
|
||||
pattern: ^(0|[1-9][0-9]*)$
|
||||
idempotencyKey:
|
||||
type: string
|
||||
minLength: 8
|
||||
maxLength: 128
|
||||
fileId:
|
||||
type: string
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.controller.DispatchVehicleDocumentController;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 排车资料复用同一车辆照片记录,不扩大旧入口或改变履约状态。 */
|
||||
class DispatchVehicleDocumentTest {
|
||||
/** 无拼车和实际拼车都能在派送后读取;原车队分类和冻结装车门禁继续生效。 */
|
||||
@Test void dispatchScopeAcceptsBothCargoGroupsOnlyAfterDeparture() {
|
||||
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();
|
||||
var queries=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchDetailReader.class));
|
||||
var source=OwnedBusinessFixtures.source();when(repo.source(source.getDispatchNo())).thenReturn(source);
|
||||
assertThrows(BizException.class,()->queries.requireScoped(source.getDispatchNo(),OwnedBusinessScope.DISPATCH));
|
||||
for(boolean pooled:List.of(false,true))for(String stage:List.of("DELIVERING","DELIVERED")) {
|
||||
source.setPooledCargo(pooled);source.setStage(stage);
|
||||
assertSame(source,queries.requireScoped(source.getDispatchNo(),OwnedBusinessScope.DISPATCH));
|
||||
}
|
||||
assertThrows(BizException.class,()->queries.requireOwned(source.getDispatchNo()));
|
||||
source.setOwnedLoadingCompletionHash("other-load");
|
||||
assertThrows(BizException.class,()->queries.requireScoped(source.getDispatchNo(),OwnedBusinessScope.DISPATCH));
|
||||
verify(repo,never()).update(any(),anyLong());
|
||||
}
|
||||
/** 排车入口只接受油耗图片;签收和装车文件仍走派送单,旧外配仍不能上传油耗。 */
|
||||
@Test void scopesKeepIndependentWritePermissionsAndPhotoCategories() {
|
||||
var scope=OwnedBusinessScope.DISPATCH;
|
||||
assertEquals("platform:overseas-warehouse:dispatch:list",scope.listPermission());
|
||||
assertEquals("platform:overseas-warehouse:dispatch:edit",scope.uploadPermission());
|
||||
for(var slot:PhotoSlot.values()) {
|
||||
if(slot.category().equals("FUEL")) { assertDoesNotThrow(()->scope.requireSlot(slot));assertThrows(BizException.class,()->OwnedBusinessScope.OUTSOURCED.requireSlot(slot)); }
|
||||
else assertThrows(BizException.class,()->scope.requireSlot(slot));
|
||||
}
|
||||
var actor=new Actor("reader","只读",Set.of(scope.listPermission()));
|
||||
var row=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()).row(OwnedBusinessFixtures.source(),null,OwnedBusinessFixtures.cargo(),List.of(),actor,scope);
|
||||
assertFalse(row.actions().upload().allowed());
|
||||
}
|
||||
/** 油耗与车队装车照片共存,排车补充油耗不破坏外配业务读取。 */
|
||||
@Test void sharedFuelPhotosDoNotInvalidateOutsourcedProjection() {
|
||||
var codec=OwnedBusinessFixtures.codec();var record=new OwnedBusinessRows.Record();record.setRecordVersion(3L);record.setArrivalsJson("[]");
|
||||
record.setPhotosJson(codec.write(List.of(new Photo(PhotoSlot.FUEL_START,"FUEL","77","fuel.png","image/png","9","2026-10-21T00:00:00Z","2026-10-21T00:00:00Z"))));
|
||||
var projection=new OwnedBusinessProjection(codec);var source=OwnedBusinessFixtures.source();source.setPooledCargo(true);source.setStage("DELIVERED");
|
||||
var row=projection.row(source,record,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor(),OwnedBusinessScope.OUTSOURCED);
|
||||
assertEquals(1,row.photoSummary().fuelCount());assertEquals("3",row.recordVersion());assertEquals(8L,source.getRecordVersion());assertEquals("DELIVERED",source.getStage());
|
||||
assertEquals("77",projection.photos(record).get(0).fileId());
|
||||
var queries=mock(OwnedBusinessQueryService.class);
|
||||
when(queries.detail(any(),eq(source.getDispatchNo()),eq(OwnedBusinessScope.OUTSOURCED)))
|
||||
.thenReturn(new Detail(row,null,List.of(),projection.photos(record)));
|
||||
var outsourced=new OutsourcedBusinessService(queries,mock(OwnedBusinessRecordService.class),mock(OwnedBusinessPhotoService.class),projection,codec);
|
||||
assertTrue(outsourced.detail(OwnedBusinessFixtures.actor(),source.getDispatchNo()).photos().isEmpty());
|
||||
}
|
||||
/** 新入口读写权限独立;服务层同样拒绝仅查看账号和车队权限绕入。 */
|
||||
@Test void controllerAndPhotoServiceKeepDispatchWriteBoundary() throws Exception {
|
||||
assertEquals("hasAuthority('platform:overseas-warehouse:dispatch:list')",Arrays.stream(DispatchVehicleDocumentController.class.getMethods())
|
||||
.filter(m->m.getName().equals("get")).findFirst().orElseThrow().getAnnotation(PreAuthorize.class).value());
|
||||
for(String name:List.of("upload","bind","recover"))assertEquals("hasAuthority('platform:overseas-warehouse:dispatch:edit')",Arrays.stream(DispatchVehicleDocumentController.class.getMethods())
|
||||
.filter(m->m.getName().equals(name)).findFirst().orElseThrow().getAnnotation(PreAuthorize.class).value());
|
||||
var queries=mock(OwnedBusinessQueryService.class);var repo=mock(OwnedBusinessRepository.class);var files=mock(OwnedBusinessFileGateway.class);var codec=OwnedBusinessFixtures.codec();
|
||||
var service=new OwnedBusinessPhotoService(queries,mock(OwnedBusinessRecordService.class),repo,mock(OwnedBusinessCommandService.class),new OwnedBusinessProjection(codec),codec,files);
|
||||
for(var actor:List.of(new Actor("read","只读",Set.of(OwnedBusinessScope.DISPATCH.listPermission())),OwnedBusinessFixtures.actor()))
|
||||
assertThrows(ResponseStatusException.class,()->service.upload(actor,"PC-1",PhotoSlot.FUEL_START,"upload-original",null,OwnedBusinessScope.DISPATCH));
|
||||
verifyNoInteractions(files,repo,queries);
|
||||
}
|
||||
}
|
||||
@@ -306,11 +306,21 @@ foreach ($document in @(
|
||||
)) { Assert-MarkdownLinks $document.Path $document.Text $document.Name }
|
||||
Assert-TableRowWidth $businessIndexPath '## 业务快速路由表' 260 '详细业务导航表'
|
||||
$businessRouteSection = (($businessIndex -split '(?m)^## 业务快速路由表\r?$', 2)[1] -split '(?m)^## ', 2)[0]
|
||||
foreach ($topic in [regex]::Matches($businessRules, '(?m)^## (?<name>.+?)\r?$')) {
|
||||
$prefix = '| ' + $topic.Groups['name'].Value + ' |'
|
||||
$hits = @($businessRouteSection -split '\r?\n' | Where-Object { $_.StartsWith($prefix) })
|
||||
# 同一规则可供多个终端入口复用;校验实际正文链接,不要求路由名称复制正文标题。
|
||||
$businessRuleSections = [regex]::Matches($businessRules, '(?ms)^## (?<name>[^\r\n]+)\r?\n(?<body>.*?)(?=^## |\z)')
|
||||
foreach ($topic in $businessRuleSections) {
|
||||
$name = $topic.Groups['name'].Value
|
||||
$slug = [regex]::Replace($name.Trim().ToLowerInvariant(), '[^\p{L}\p{M}\p{N}_\s-]', '') -replace '\s+', '-'
|
||||
$target = '业务实现边界.md#' + $slug
|
||||
$hasRoute = $businessRouteSection.Contains('](' + $target + ')')
|
||||
if (-not $hasRoute) {
|
||||
# 已有正式正文时允许索引直接跳转,避免为覆盖率强加一层摘要。
|
||||
$sourceLine = [regex]::Match($topic.Groups['body'].Value, '(?m)^范围依据:[^\r\n]+')
|
||||
$sourceLink = [regex]::Match($sourceLine.Value, '\[[^\]]+\]\((?<target>[^)]+)\)')
|
||||
$hasRoute = $sourceLink.Success -and $businessRouteSection.Contains('](' + $sourceLink.Groups['target'].Value + ')')
|
||||
}
|
||||
$checked++
|
||||
if ($hits.Count -ne 1) { Add-Failure "业务正文必须有唯一导航入口:$($topic.Groups['name'].Value)" }
|
||||
if (-not $hasRoute) { Add-Failure "业务正文或其正式依据缺少导航入口:$name" }
|
||||
}
|
||||
Assert-Contains $rootAgents '备选导航' '索引不是串行读取清单'
|
||||
Assert-Contains $rootAgents '命中正文即停止扩展' '正文命中后的读取停止条件'
|
||||
@@ -320,19 +330,34 @@ Assert-Contains $financeP1 '首批固定为 CNY' '资金账户币种规则迁入
|
||||
Assert-Contains $financeP1 '充值先归还已用授信或负余额' '资金账户充值规则迁入现行正文'
|
||||
Assert-Contains $financeP1 '新账户默认授信为 `0.00`' '资金账户默认授信规则迁入现行正文'
|
||||
$docLookupCharacters = 0
|
||||
$walletRuleTarget = '财务管理/财务对账结算P1闭环.md#客户资金账户'
|
||||
$docLookupSamples = @(
|
||||
@{ Key = '客户头程订单批量导入'; Text = $businessIndex; Entry = 'apps/client-web/src/modules/first-leg/AGENTS.md' },
|
||||
@{ Key = '客户资金账户、充值、授信与业务扣费'; Text = $businessIndex; Entry = 'apps/client-web/AGENTS.md' },
|
||||
@{ Key = '客户账单、客户对账单、账单确认'; Text = $businessIndex; Entry = 'apps/client-web/src/modules/finance/views/FinanceBillsView.vue' },
|
||||
@{ Key = '客户资金账户、充值、授信、冻结与业务扣费'; Text = $financeDocEntry; Entry = '财务对账结算P1闭环.md#客户资金账户' },
|
||||
@{ Key = '海外排车、出库与派送改造'; Text = $overseasDocEntry; Entry = '排车列表统一作业改造契约.md' },
|
||||
@{ Key = '本土卡车与散板表单'; Text = $overseasDocEntry; Entry = '业务规则.md#本土散板简化下单' }
|
||||
@{ Key = '客户头程订单批量导入'; Text = $businessIndex; Column = 1; Entry = 'apps/client-web/src/modules/first-leg/AGENTS.md' },
|
||||
@{ Key = '平台资金账户、充值审核、手工充扣与授信'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/finance/AGENTS.md'; Rules = $walletRuleTarget },
|
||||
@{ Key = '客户资金账户、余额、授信与充值申请'; Text = $businessIndex; Column = 1; Entry = 'apps/client-web/src/modules/finance/AGENTS.md'; Rules = $walletRuleTarget },
|
||||
@{ Key = '服务端资金账户、授信、冻结、结算与业务扣费'; Text = $businessIndex; Column = 1; Entry = 'baiyue-firstleg/finance/AGENTS.md'; Rules = $walletRuleTarget },
|
||||
@{ Key = '海外仓库主档、名称、地址与能力'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/warehouses/AGENTS.md'; Rules = '业务实现边界.md#海外仓库与通用资料' },
|
||||
@{ Key = '海外仓库主档、名称、地址与能力'; Text = $businessIndex; Column = 1; Entry = 'baiyue-firstleg/warehouse/AGENTS.md'; Rules = '业务实现边界.md#海外仓库与通用资料' },
|
||||
@{ Key = '海外库位、托盘与资源锁'; Text = $businessIndex; Column = 1; Entry = 'baiyue-firstleg/warehouse-overseas-service/AGENTS.md'; Rules = '业务实现边界.md#海外仓库与通用资料' },
|
||||
@{ Key = '客户统计与运营指标'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/customers/AGENTS.md' },
|
||||
@{ Key = '平台日志、操作日志、审计日志与操作人缺失'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/audit/AGENTS.md' },
|
||||
@{ Key = '客户账单、客户对账单、账单确认'; Text = $businessIndex; Column = 2; Entry = 'apps/client-web/src/modules/finance/views/FinanceBillsView.vue' },
|
||||
@{ Key = '客户资金账户、充值、授信、冻结与业务扣费'; Text = $financeDocEntry; Column = 1; Entry = '财务对账结算P1闭环.md#客户资金账户' },
|
||||
@{ Key = '海外排车、出库与派送改造'; Text = $overseasDocEntry; Column = 1; Entry = '排车列表统一作业改造契约.md' },
|
||||
@{ Key = '本土卡车与散板表单'; Text = $overseasDocEntry; Column = 1; Entry = '业务规则.md#本土散板简化下单' }
|
||||
)
|
||||
foreach ($sample in $docLookupSamples) {
|
||||
$hits = @($sample.Text -split '\r?\n' | Where-Object { $_.StartsWith('| ' + $sample.Key + ' |') })
|
||||
$checked++
|
||||
if ($hits.Count -ne 1) { Add-Failure "文档路由样本必须唯一命中:$($sample.Key)"; continue }
|
||||
Assert-Contains $hits[0] $sample.Entry "文档路由样本 $($sample.Key)"
|
||||
# 代码入口与规则正文分别核对,不能靠其他列碰巧包含目标路径通过。
|
||||
$cells = @($hits[0].Trim('|').Split('|') | ForEach-Object { $_.Trim() })
|
||||
if ($sample.Column -ge $cells.Count) { Add-Failure "文档路由样本缺少目标列:$($sample.Key)"; continue }
|
||||
Assert-Contains $cells[$sample.Column] $sample.Entry "文档路由样本 $($sample.Key)"
|
||||
if ($sample.Rules) {
|
||||
if ($cells.Count -lt 3) { Add-Failure "文档路由样本缺少规则正文:$($sample.Key)"; continue }
|
||||
Assert-Contains $cells[2] $sample.Rules "文档正文样本 $($sample.Key)"
|
||||
}
|
||||
$docLookupCharacters += $hits[0].Length
|
||||
}
|
||||
Write-Host "文档路由样本:$($docLookupSamples.Count) 项,命中行共 $docLookupCharacters 字符(不含上层指引或按需正文,不代表端到端耗时)。"
|
||||
|
||||
Reference in New Issue
Block a user