优化
This commit is contained in:
+80
@@ -0,0 +1,80 @@
|
||||
package com.baiyue.lastleg.fleet.outsourcedbusiness.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Query;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport;
|
||||
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/fleet/outsourced-business")
|
||||
public class OutsourcedBusinessController {
|
||||
private final OutsourcedBusinessService service;
|
||||
private final OwnedBusinessSupport access;
|
||||
/** 会话只转真实平台操作者及其原权限集合。 */
|
||||
public OutsourcedBusinessController(OutsourcedBusinessService service,OwnedBusinessSupport access){this.service=service;this.access=access;}
|
||||
/** 同筛选分页和完整阶段统计一次返回。 */
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
|
||||
public ApiResponse<ListResult> list(@AuthenticationPrincipal AuthenticatedUser user,@Valid Query query){return ApiResponse.success(service.list(actor(user),query));}
|
||||
/** 列表权限包含详情,不借原排车或自有写权限。 */
|
||||
@GetMapping("/{dispatchNo}")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
|
||||
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo){return ApiResponse.success(service.detail(actor(user),dispatchNo));}
|
||||
/** 原主单和本页附加历史保持各自版本字段。 */
|
||||
@GetMapping("/{dispatchNo}/history")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
|
||||
public ApiResponse<HistoryPage> history(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
|
||||
@RequestParam(defaultValue="1") @Min(1) Integer page,@RequestParam(defaultValue="20") @Min(1) @Max(200) Integer size) {
|
||||
return ApiResponse.success(service.history(actor(user),dispatchNo,page,size));
|
||||
}
|
||||
/** 只登记整车金额和逐目的仓到达,不分摊或触发财务。 */
|
||||
@PutMapping("/{dispatchNo}/operations")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:edit')")
|
||||
public ApiResponse<Detail> save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@Valid @RequestBody SaveOperations request) {
|
||||
return ApiResponse.success(service.save(actor(user),dispatchNo,request));
|
||||
}
|
||||
/** 上传仅准备照片文件,幂等键必须在multipart内。 */
|
||||
@PostMapping(value="/{dispatchNo}/photos/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:upload')")
|
||||
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(actor(user),dispatchNo,slot,idempotencyKey,file));
|
||||
}
|
||||
/** 绑定使用独立原键和运营附加版本,失败保留fileId。 */
|
||||
@PutMapping("/{dispatchNo}/photos/{slot}")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:upload')")
|
||||
public ApiResponse<Detail> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) {
|
||||
return ApiResponse.success(service.bind(actor(user),dispatchNo,slot,request));
|
||||
}
|
||||
/** 当前或指定历史引用经过外配归属与权限校验后代理读取。 */
|
||||
@GetMapping("/{dispatchNo}/photos/{slot}/files/{fileId}/content")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
|
||||
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
|
||||
@PathVariable @Positive long fileId,@RequestParam(required=false) @Min(1) Long revisionVersion) {
|
||||
var result=service.content(actor(user),dispatchNo,slot,fileId,revisionVersion);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(result.photo().contentType()))
|
||||
.header("X-Content-Type-Options","nosniff").body(result.content());
|
||||
}
|
||||
/** 原键仅返回原操作者的结果,不自动补写或换键重试。 */
|
||||
@GetMapping("/{dispatchNo}/commands/{idempotencyKey}")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
|
||||
public ApiResponse<Command> command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
|
||||
return ApiResponse.success(service.command(actor(user),dispatchNo,idempotencyKey));
|
||||
}
|
||||
/** 固定外配入口保留会话真实权限,不接受任何浏览器作用域。 */
|
||||
private Actor actor(AuthenticatedUser user){return access.actor(user,OwnedBusinessScope.OUTSOURCED);}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.baiyue.lastleg.fleet.outsourcedbusiness.model;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchViews;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Arrival;
|
||||
import com.fasterxml.jackson.annotation.JsonAnySetter;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.util.List;
|
||||
|
||||
/** 外配公开合同单独表达五框、承运金额和到达,不公开里程油耗字段。 */
|
||||
public final class OutsourcedBusinessModels {
|
||||
/** DTO容器不允许实例化。 */
|
||||
private OutsourcedBusinessModels() {}
|
||||
public static final String LIST="platform:fleet:outsourced-business:list";
|
||||
public static final String EDIT="platform:fleet:outsourced-business:edit";
|
||||
public static final String UPLOAD="platform:fleet:outsourced-business:upload";
|
||||
public enum PhotoSlot { LOADING_SCENE,LOADING_LIST,PRE_DEPARTURE_CHECK,DRIVER_LICENSE,RECEIPT }
|
||||
public record Operations(@Valid OwnedBusinessModels.Money money,@NotNull List<@NotNull @Valid Arrival> arrivals) {
|
||||
/** 不依赖全局Jackson未知字段开关,明确拒绝油耗、里程及浏览器归属。 */
|
||||
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
|
||||
}
|
||||
public record SaveOperations(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
|
||||
@NotNull @Valid Operations operations,@Size(max=1000) String reason) {
|
||||
/** 固定外配入口不接收浏览器作用域或其他协议字段。 */
|
||||
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
|
||||
}
|
||||
public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {
|
||||
/** 绑定只接受原版本、原键和File编号,不能提交车辆归属。 */
|
||||
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
|
||||
}
|
||||
public record Vehicle(String vehicleId,String plate,String ownership,String carrierName) {}
|
||||
public record PhotoSummary(int loadingCount) {}
|
||||
public record Photo(PhotoSlot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {}
|
||||
public record Row(String dispatchNo,OwnedBusinessModels.Stage stage,String plannedDepartureAt,String loadingCompletedAt,String departedAt,String deliveryCompletedAt,
|
||||
OwnedBusinessModels.Origin origin,Vehicle vehicle,OwnedBusinessModels.Driver driver,List<OwnedBusinessModels.Destination> destinations,
|
||||
Integer actualPalletCount,Operations operations,String unitPrice,String recordVersion,String dispatchRecordVersion,
|
||||
PhotoSummary photoSummary,OwnedBusinessModels.Actions actions,List<String> sourceIssues) {}
|
||||
public record ListResult(List<Row> records,int page,int size,int total,OwnedBusinessModels.Summary summary) {}
|
||||
public record Detail(Row row,DispatchViews.Detail dispatch,List<OwnedBusinessModels.Cargo> cargo,List<Photo> photos) {}
|
||||
public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,Operations operations,List<Photo> photos) {}
|
||||
public record HistoryPage(List<History> records,int page,int size,int total) {}
|
||||
public record Command(String dispatchNo,String action,String idempotencyKey,String state,String recordVersion,boolean replayed,Object result,String errorCode,boolean retryAllowed) {}
|
||||
/** 不支持字段作为明确业务输入错误,不能静默隐藏或降级解析。 */
|
||||
private static BizException unsupportedField(String field){return new BizException(ResultCode.BAD_REQUEST,"外配业务不支持字段:"+field);}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.baiyue.lastleg.fleet.outsourcedbusiness.service;
|
||||
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessQueryService;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessRecordService;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessPhotoService;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessProjection;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import java.util.List;
|
||||
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.bad;
|
||||
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.conflict;
|
||||
|
||||
/** 固定外配入口复用四表及原键机制,不伪造权限、不复制业务写实现。 */
|
||||
@Service
|
||||
public class OutsourcedBusinessService {
|
||||
private static final OwnedBusinessScope SCOPE=OwnedBusinessScope.OUTSOURCED;
|
||||
private final OwnedBusinessQueryService queries;
|
||||
private final OwnedBusinessRecordService records;
|
||||
private final OwnedBusinessPhotoService photos;
|
||||
private final OwnedBusinessProjection projection;
|
||||
private final OwnedBusinessSupport codec;
|
||||
/** 只通过内部服务复用事务与存储,Controller不接触Repository。 */
|
||||
public OutsourcedBusinessService(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,OwnedBusinessPhotoService photos,
|
||||
OwnedBusinessProjection projection,OwnedBusinessSupport codec){this.queries=queries;this.records=records;this.photos=photos;this.projection=projection;this.codec=codec;}
|
||||
/** 列表承运商随批量来源读取,四项统计保持同筛选完整集合。 */
|
||||
public ListResult list(OwnedBusinessModels.Actor actor,OwnedBusinessModels.Query query) {
|
||||
var result=queries.listScoped(actor,query,SCOPE);var page=result.result();
|
||||
return new ListResult(page.records().stream().map(r->row(r,result.carrierNames().get(r.dispatchNo()))).toList(),page.page(),page.size(),page.total(),page.summary());
|
||||
}
|
||||
/** 三个页签读取同一原排车详情和外配附加记录。 */
|
||||
public Detail detail(OwnedBusinessModels.Actor actor,String number){return detail(queries.detail(actor,number,SCOPE));}
|
||||
/** 原主单与运营历史共用分页,不在外配DTO输出油耗字段。 */
|
||||
public HistoryPage history(OwnedBusinessModels.Actor actor,String number,Integer page,Integer size) {
|
||||
var result=queries.history(actor,number,page,size,SCOPE);
|
||||
return new HistoryPage(result.records().stream().map(h->new History(h.eventSource(),h.recordVersion(),h.dispatchRecordVersion(),h.action(),h.operatorName(),
|
||||
h.occurredAt(),h.reason(),h.operations()==null?null:operations(h.operations()),h.photos().stream().map(this::photo).toList())).toList(),result.page(),result.size(),result.total());
|
||||
}
|
||||
/** 外配只生成金额和到达的规范内部请求,不接收归属或隐藏油耗写入。 */
|
||||
public Detail save(OwnedBusinessModels.Actor actor,String number,SaveOperations request) {
|
||||
if(request==null || request.operations()==null)throw bad("运营登记内容缺失");
|
||||
var ops=request.operations();
|
||||
return detail(records.save(actor,number,new OwnedBusinessModels.SaveOperations(request.recordVersion(),request.idempotencyKey(),
|
||||
new OwnedBusinessModels.Operations(ops.money(),null,null,ops.arrivals()),request.reason()),SCOPE));
|
||||
}
|
||||
/** 五框使用已发布装车File分类,上传与绑定分别保留稳定键。 */
|
||||
public Photo upload(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,String key,MultipartFile file) {
|
||||
return photo(photos.upload(actor,number,slot(slot),key,file,SCOPE));
|
||||
}
|
||||
/** 原File编号由共享绑定事务校验,不创建第二份附件或写原主单。 */
|
||||
public Detail bind(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,BindPhoto request) {
|
||||
if(request==null)throw bad("照片绑定请求缺失");
|
||||
return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId()),SCOPE));
|
||||
}
|
||||
/** 当前或历史引用读取始终限定原车、照片框和外配权限。 */
|
||||
public OwnedBusinessPhotoService.Download content(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
|
||||
return photos.content(actor,number,slot(slot),fileId,revision,SCOPE);
|
||||
}
|
||||
/** 原键结果转换成外配原始快照,不重算金额或使用现在的车辆档案。 */
|
||||
public Command command(OwnedBusinessModels.Actor actor,String number,String key) {
|
||||
var original=photos.command(actor,number,key,SCOPE);Object result=null;
|
||||
if(original.result()!=null)result=switch(original.action()) {
|
||||
case "SAVE_OPERATIONS","BIND_PHOTO"->detail(codec.read(codec.write(original.result()),OwnedBusinessModels.Detail.class));
|
||||
case "UPLOAD_PHOTO"->photo(codec.read(codec.write(original.result()),OwnedBusinessModels.Photo.class));
|
||||
default->throw conflict("原命令类型不属于外配业务");
|
||||
};
|
||||
return new Command(original.dispatchNo(),original.action(),original.idempotencyKey(),original.state(),original.recordVersion(),original.replayed(),result,original.errorCode(),original.retryAllowed());
|
||||
}
|
||||
/** 回执公开形状不带内部里程、油耗或油耗照片摘要。 */
|
||||
private Detail detail(OwnedBusinessModels.Detail detail) {
|
||||
return new Detail(row(detail.row(),projection.carrierName(detail)),detail.dispatch(),detail.cargo(),detail.photos().stream().map(this::photo).toList());
|
||||
}
|
||||
/** 临时车辆合法保留空vehicleId;承运商缺失保留null供页面显示破折号。 */
|
||||
private Row row(OwnedBusinessModels.Row r,String carrierName) {
|
||||
if(!"DISPATCHED".equals(r.vehicle().ownership()) || r.photoSummary().fuelCount()!=0)throw conflict("外配运营快照与冻结归属不一致");
|
||||
return new Row(r.dispatchNo(),r.stage(),r.plannedDepartureAt(),r.loadingCompletedAt(),r.departedAt(),r.deliveryCompletedAt(),r.origin(),
|
||||
new Vehicle(r.vehicle().vehicleId(),r.vehicle().plate(),r.vehicle().ownership(),carrierName==null || carrierName.isBlank()?null:carrierName),r.driver(),r.destinations(),
|
||||
r.actualPalletCount(),operations(r.operations()),r.unitPrice(),r.recordVersion(),r.dispatchRecordVersion(),new PhotoSummary(r.photoSummary().loadingCount()),r.actions(),r.sourceIssues());
|
||||
}
|
||||
/** 拒绝异常持久化油耗,不用DTO隐藏不受支持的数据。 */
|
||||
private Operations operations(OwnedBusinessModels.Operations ops) {
|
||||
if(ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null)throw conflict("外配业务不能包含里程或油耗登记");
|
||||
return new Operations(ops.money(),ops.arrivals());
|
||||
}
|
||||
/** 外配照片枚举只有五框,异常油耗历史不能借复用向外输出。 */
|
||||
private Photo photo(OwnedBusinessModels.Photo p) {
|
||||
SCOPE.requireSlot(p.slot());return new Photo(PhotoSlot.valueOf(p.slot().name()),p.category(),p.fileId(),p.fileName(),p.contentType(),p.sizeBytes(),p.uploadedAt(),p.savedAt());
|
||||
}
|
||||
/** 显式枚举转换不接受别名、兼容值或浏览器分类。 */
|
||||
private OwnedBusinessModels.PhotoSlot slot(PhotoSlot slot){if(slot==null)throw bad("请选择装车照片框");return OwnedBusinessModels.PhotoSlot.valueOf(slot.name());}
|
||||
}
|
||||
+9
-3
@@ -68,14 +68,20 @@ public interface OwnedBusinessMapper {
|
||||
#{reason},#{snapshotJson},#{occurredAt})
|
||||
""")
|
||||
int insertHistory(History row);
|
||||
/** 历史按本页版本顺序分页。 */
|
||||
/** 两表文本转连接字符规则后合并,长度取原值以保留中文及真实尾空格;不改原历史或JSON。 */
|
||||
@Select("""
|
||||
SELECT * FROM (
|
||||
SELECT id,dispatch_id,record_version,NULL AS dispatch_record_version,'OPERATIONS' AS event_source,
|
||||
action,operator_id,reason,snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id}
|
||||
RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action,
|
||||
RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id,
|
||||
RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason,
|
||||
snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id}
|
||||
UNION ALL
|
||||
SELECT id,dispatch_id,NULL AS record_version,record_version AS dispatch_record_version,'DISPATCH' AS event_source,
|
||||
action,operator_id,reason,snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id}
|
||||
RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action,
|
||||
RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id,
|
||||
RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason,
|
||||
snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id}
|
||||
) events ORDER BY occurred_at DESC,event_source,id DESC LIMIT #{limit} OFFSET #{offset}
|
||||
""")
|
||||
List<History> history(@Param("id") long id,@Param("offset") int offset,@Param("limit") int limit);
|
||||
|
||||
+5
-1
@@ -16,10 +16,14 @@ public interface OwnedBusinessQueryMapper {
|
||||
o.loading_completion_hash AS owned_loading_completion_hash,o.loading_completed_at AS owned_loading_completed_at
|
||||
FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id
|
||||
""";
|
||||
/** 摘要按列上限128字节完整比较,不受新旧表默认排序规则影响,不能省略二进制宽度。 */
|
||||
String FILTER="""
|
||||
d.status='ACTIVE' AND d.stage IN ('AFTER_LOADING','DELIVERING','DELIVERED')
|
||||
AND d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL
|
||||
AND o.vehicle_ownership='OWNED' AND o.loading_completion_hash=d.loading_completion_hash
|
||||
AND o.vehicle_ownership=#{q.scope.ownership}
|
||||
AND CAST(o.loading_completion_hash AS BINARY(128))=CAST(d.loading_completion_hash AS BINARY(128))
|
||||
AND o.loading_completed_at=d.loading_completed_at
|
||||
AND (o.resource_mode='MASTER' OR (#{q.scope.ownership}='DISPATCHED' AND o.resource_mode='EXTERNAL'))
|
||||
<if test='q.from != null'> AND d.planned_departure_at >= #{q.from}</if>
|
||||
<if test='q.to != null'> AND d.planned_departure_at < #{q.to}</if>
|
||||
<if test='q.stage != null'> AND d.stage=#{q.stage}</if>
|
||||
|
||||
+1
-1
@@ -52,6 +52,6 @@ public final class OwnedBusinessRows {
|
||||
private Long allocatedPackages;
|
||||
}
|
||||
public record Search(int offset,int limit,String keyword,String warehouseId,String stage,
|
||||
LocalDateTime from,LocalDateTime to) {}
|
||||
LocalDateTime from,LocalDateTime to,OwnedBusinessScope scope) {}
|
||||
@Data public static class StageCount { private String stage;private Long quantity; }
|
||||
}
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness.model;
|
||||
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
|
||||
/** 仅服务端固定入口选择的作用域;不得成为浏览器请求字段或权限别名。 */
|
||||
public enum OwnedBusinessScope {
|
||||
OWNED("OWNED","platform:fleet:owned-business:"),
|
||||
OUTSOURCED("DISPATCHED","platform:fleet:outsourced-business:");
|
||||
private final String ownership,prefix;
|
||||
/** 车辆归属与入口权限一一固定,不能由请求指定。 */
|
||||
OwnedBusinessScope(String ownership,String prefix){this.ownership=ownership;this.prefix=prefix;}
|
||||
/** SQL仅绑定此可信枚举的归属值,不拼接客户端片段。 */
|
||||
public String getOwnership(){return ownership;}
|
||||
/** 查看权限包含本作用域详情、历史和照片读取。 */
|
||||
public String listPermission(){return prefix+"list";}
|
||||
/** 运营写权限独立,外配权限不映射成自有权限。 */
|
||||
public String editPermission(){return prefix+"edit";}
|
||||
/** 照片写权限独立,不扩大到原排车。 */
|
||||
public String uploadPermission(){return prefix+"upload";}
|
||||
/** 外配只支持装车五框,服务调用同样不能绕过。 */
|
||||
public void requireSlot(PhotoSlot slot){if(slot==null || this==OUTSOURCED && !"LOADING".equals(slot.category()))throw new BizException(ResultCode.BAD_REQUEST,"当前业务不支持该照片框");}
|
||||
/** 不存在只显示当前业务归属,不泄露内部身份。 */
|
||||
public BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前"+(this==OWNED?"自有":"外配")+"业务");}
|
||||
}
|
||||
+31
-10
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.HttpOwnedBusinessFileGateway;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
@@ -32,7 +33,12 @@ public class OwnedBusinessPhotoService {
|
||||
public record Download(Photo photo,byte[] content) {}
|
||||
/** 写前落意图,绑定失败须保留原fileId,不能再上传。 */
|
||||
public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart) {
|
||||
codec.require(actor,UPLOAD);codec.key(key);var source=queries.requireOwned(number);
|
||||
return upload(actor,number,slot,key,multipart,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 归属与实际上传权限在写File意图前核验,外配拒绝两个油耗框。 */
|
||||
public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.uploadPermission());scope.requireSlot(slot);codec.key(key);
|
||||
var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);
|
||||
if(slot==null || multipart==null)throw bad("请选择照片及分类");
|
||||
if(multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB");
|
||||
byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException error){throw bad("无法读取待上传照片");}
|
||||
@@ -49,41 +55,56 @@ public class OwnedBusinessPhotoService {
|
||||
commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return result;
|
||||
} catch(RuntimeException failure) {
|
||||
// 连接异常不能证明File没有写入;持久保留未知结果,禁止自动补造。
|
||||
try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,"OWNED_UPLOAD_UNKNOWN_RESULT");}
|
||||
try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,scope==OwnedBusinessScope.OWNED?"OWNED_UPLOAD_UNKNOWN_RESULT":"OUTSOURCED_UPLOAD_UNKNOWN_RESULT");}
|
||||
catch(RuntimeException recordingFailure){failure.addSuppressed(recordingFailure);}
|
||||
throw conflict("照片上传结果未知,请保留原请求键查询,勿重新上传");
|
||||
}
|
||||
}
|
||||
/** 重放先于File复核,保证原成功结果可以精确恢复。 */
|
||||
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request) {
|
||||
codec.require(actor,UPLOAD);if(request==null || slot==null)throw bad("照片绑定请求缺失");
|
||||
return bind(actor,number,slot,request,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 原绑定键恢复仍先检查固定业务域和当前会话,不重新上传。 */
|
||||
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.uploadPermission());if(request==null || slot==null)throw bad("照片绑定请求缺失");scope.requireSlot(slot);
|
||||
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());long fileId=codec.version(request.fileId());
|
||||
if(fileId<=0)throw bad("文件编号无效");
|
||||
var source=queries.requireOwned(number);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||
var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||
if(previous!=null) {
|
||||
commands.same(previous,"BIND_PHOTO",version,new OwnedBusinessRecordService.PhotoBinding(slot,request));
|
||||
if(!"SUCCEEDED".equals(previous.getStatus()))throw conflict("绑定结果尚未明确,请查询原命令");
|
||||
return codec.read(previous.getResultJson(),Detail.class);
|
||||
}
|
||||
return records.bind(actor,number,slot,request,photo(slot,files.metadata(source.getId(),slot,fileId)));
|
||||
var verified=photo(slot,files.metadata(source.getId(),slot,fileId));
|
||||
return scope==OwnedBusinessScope.OWNED?records.bind(actor,number,slot,request,verified):records.bind(actor,number,slot,request,verified,scope);
|
||||
}
|
||||
/** 只有当前或指定真实历史版本中的引用允许代理读取。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public Download content(Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
|
||||
codec.require(actor,LIST);var source=queries.requireOwned(number);List<Photo> bound;
|
||||
return content(actor,number,slot,fileId,revision,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 五框历史授权仍由同车同版本的真实引用限定。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public Download content(Actor actor,String number,PhotoSlot slot,long fileId,Long revision,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.listPermission());scope.requireSlot(slot);var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);List<Photo> bound;
|
||||
if(revision==null)bound=projection.photos(repository.record(source.getId()));
|
||||
else {
|
||||
if(revision<1)throw bad("历史版本无效");var row=repository.revision(source.getId(),revision);
|
||||
if(row==null)throw missing();bound=codec.read(row.getSnapshotJson(),Snapshot.class).photos();
|
||||
if(row==null)throw scope.missing();bound=codec.read(row.getSnapshotJson(),Snapshot.class).photos();
|
||||
}
|
||||
var photo=bound.stream().filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst().orElseThrow(OwnedBusinessSupport::missing);
|
||||
var photo=bound.stream().filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst().orElseThrow(scope::missing);
|
||||
return new Download(photo,files.content(source.getId(),slot,fileId));
|
||||
}
|
||||
/** 恢复先校验原操作者,不披露他人载荷。 */
|
||||
@Transactional(readOnly=true)
|
||||
public Command command(Actor actor,String number,String key) {
|
||||
codec.require(actor,LIST);codec.key(key);var source=queries.requireOwned(number);var row=commands.existing(source.getId(),key,actor);
|
||||
if(row==null)throw missing();Object result=row.getResultJson()==null?null:codec.read(row.getResultJson(),Object.class);
|
||||
return command(actor,number,key,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 原键查询不能越过归属隔离,外配权限不授权自有账本。 */
|
||||
@Transactional(readOnly=true)
|
||||
public Command command(Actor actor,String number,String key,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.listPermission());codec.key(key);var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var row=commands.existing(source.getId(),key,actor);
|
||||
if(row==null)throw scope.missing();Object result=row.getResultJson()==null?null:codec.read(row.getResultJson(),Object.class);
|
||||
String version=row.getExpectedVersion()==null?null:String.valueOf(Math.addExact(row.getExpectedVersion(),1));
|
||||
return new Command(number,row.getAction(),key,row.getStatus(),version,true,result,row.getErrorCode(),false);
|
||||
}
|
||||
|
||||
+16
-2
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.dispatch.port.DispatchContracts.ConfirmedLoad;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
|
||||
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.model.OwnedBusinessRows.Record;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -45,9 +46,15 @@ public class OwnedBusinessProjection {
|
||||
}
|
||||
/** 一车一行;目的地与预约只行内展开,不重复整车金额或托数。 */
|
||||
public Row row(Source source,Record record,List<CargoRow> cargo,List<Batch> batches,Actor actor) {
|
||||
return row(source,record,cargo,batches,actor,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 两类固定入口复用原事实装配,公开DTO由各域独立转换。 */
|
||||
public Row row(Source source,Record record,List<CargoRow> cargo,List<Batch> batches,Actor actor,OwnedBusinessScope scope) {
|
||||
var resources=codec.read(source.getResourcesJson(),Resources.class);
|
||||
if(resources.vehicle()==null)throw OwnedBusinessSupport.conflict("原车辆快照缺失");
|
||||
var ops=operations(record);var photos=photos(record);int pallets=palletCount(source);
|
||||
if(scope==OwnedBusinessScope.OUTSOURCED && (ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null
|
||||
|| photos.stream().anyMatch(p->!"LOADING".equals(p.category()))))throw OwnedBusinessSupport.conflict("外配运营记录含不支持的里程、油耗或照片");
|
||||
var arrivals=new HashMap<String,String>();for(var a:ops.arrivals())arrivals.put(a.destinationKey(),a.actualArrivedAt());
|
||||
var destinations=new LinkedHashMap<String,Destination>();
|
||||
for(var item:cargo) {
|
||||
@@ -61,11 +68,18 @@ public class OwnedBusinessProjection {
|
||||
return new Row(source.getDispatchNo(),Stage.valueOf(source.getStage()),codec.iso(source.getPlannedDepartureAt()),
|
||||
codec.iso(source.getLoadingCompletedAt()),codec.iso(source.getDepartedAt()),codec.iso(source.getDeliveryCompletedAt()),
|
||||
new Origin(source.getOverseasWarehouseId(),source.getOverseasWarehouseCode(),source.getOverseasWarehouseName()),
|
||||
new Vehicle(String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),"OWNED"),
|
||||
new Vehicle(resources.vehicle().vehicleId()==null?null:String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),scope.getOwnership()),
|
||||
new Driver(driver==null?null:driver.name(),driver==null?null:driver.phone()),List.copyOf(destinations.values()),pallets,ops,price,
|
||||
record==null?"0":String.valueOf(record.getRecordVersion()),String.valueOf(source.getRecordVersion()),
|
||||
new PhotoSummary((int)photos.stream().filter(p->"LOADING".equals(p.category())).count(),(int)photos.stream().filter(p->"FUEL".equals(p.category())).count()),
|
||||
new Actions(gate(actor,EDIT),gate(actor,UPLOAD)),pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of());
|
||||
new Actions(gate(actor,scope.editPermission()),gate(actor,scope.uploadPermission())),pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of());
|
||||
}
|
||||
/** 承运商仅取原资源快照,列表不逐车查询当前档案。 */
|
||||
public String carrierName(Source source){return codec.read(source.getResourcesJson(),Resources.class).vehicle().carrierName();}
|
||||
/** 原键回执的承运商沿原详情快照,不重新读取当前资源。 */
|
||||
public String carrierName(Detail detail) {
|
||||
return detail.dispatch()==null || detail.dispatch().vehicleInfo()==null || detail.dispatch().vehicleInfo().vehicle()==null
|
||||
?null:detail.dispatch().vehicleInfo().vehicle().carrierName();
|
||||
}
|
||||
/** 附加权限独立于原排车写动作。 */
|
||||
private Gate gate(Actor actor,String permission) {
|
||||
|
||||
+44
-14
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
||||
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Search;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
|
||||
@@ -29,12 +30,19 @@ public class OwnedBusinessQueryService {
|
||||
/** 四项统计与分页使用同一过滤和快照,GET不写库。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public ListResult list(Actor actor,Query query) {
|
||||
codec.require(actor,LIST);if(query==null)throw bad("查询条件缺失");
|
||||
return listScoped(actor,query,OwnedBusinessScope.OWNED).result();
|
||||
}
|
||||
/** 内部批量结果额外带原承运商;不扩展自有公开Row,不逐车补查。 */
|
||||
public record ScopedListResult(ListResult result,Map<String,String> carrierNames) {}
|
||||
/** 可信作用域只由服务调用方指定,权限与SQL同时限定同一域。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public ScopedListResult listScoped(Actor actor,Query query,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.listPermission());if(query==null)throw bad("查询条件缺失");
|
||||
int page=page(query.page()),size=size(query.size());String keyword=optional(query.keyword(),128),warehouse=optional(query.warehouseId(),64);
|
||||
Instant from=codec.instant(query.plannedDepartureFrom()),to=codec.instant(query.plannedDepartureTo());
|
||||
if(from!=null && to!=null && !from.isBefore(to))throw bad("计划发车时间范围无效");
|
||||
int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");}
|
||||
var search=new Search(offset,size,keyword,warehouse,query.stage()==null?null:query.stage().name(),codec.utc(from),codec.utc(to));
|
||||
var search=new Search(offset,size,keyword,warehouse,query.stage()==null?null:query.stage().name(),codec.utc(from),codec.utc(to),scope);
|
||||
var counts=repository.summary(search);int after=0,delivering=0,delivered=0;
|
||||
for(var c:counts) {
|
||||
int n=Math.toIntExact(c.getQuantity());
|
||||
@@ -44,18 +52,26 @@ public class OwnedBusinessQueryService {
|
||||
var sources=repository.page(search);var ids=sources.stream().map(Source::getId).toList();
|
||||
var records=repository.records(ids).stream().collect(Collectors.toMap(Record::getDispatchId,r->r));
|
||||
var cargo=repository.cargo(ids);var batches=repository.batches(ids);
|
||||
var rows=sources.stream().map(s->{eligible(s);return projection.row(s,records.get(s.getId()),
|
||||
var rows=sources.stream().map(s->{eligible(s,scope);return projection.row(s,records.get(s.getId()),
|
||||
cargo.stream().filter(c->Objects.equals(c.getDispatchOrderId(),s.getId())).toList(),
|
||||
batches.stream().filter(b->Objects.equals(b.getDispatchId(),s.getId())).toList(),actor);}).toList();
|
||||
return new ListResult(rows,page,size,total,new Summary(total,after,delivering,delivered));
|
||||
batches.stream().filter(b->Objects.equals(b.getDispatchId(),s.getId())).toList(),actor,scope);}).toList();
|
||||
var carriers=new LinkedHashMap<String,String>();for(var source:sources)carriers.put(source.getDispatchNo(),projection.carrierName(source));
|
||||
return new ScopedListResult(new ListResult(rows,page,size,total,new Summary(total,after,delivering,delivered)),Collections.unmodifiableMap(carriers));
|
||||
}
|
||||
/** 对原排车详情做组合,不创建第二份主单或履约状态。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public Detail detail(Actor actor,String number){codec.require(actor,LIST);return detailForWrite(actor,requireOwned(number));}
|
||||
/** 外配入口只能读取其冻结归属,不能借自有权限取得原详情。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public Detail detail(Actor actor,String number,OwnedBusinessScope scope){codec.require(actor,scope.listPermission());return detailForWrite(actor,requireScoped(number,scope),scope);}
|
||||
/** 写入已经有独立权限;回执仍由相同真实来源组成。 */
|
||||
public Detail detailForWrite(Actor actor,Source source) {
|
||||
eligible(source);var ids=List.of(source.getId());var cargo=repository.cargo(ids);
|
||||
var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor);
|
||||
return detailForWrite(actor,source,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 写回执也执行固定归属校验,调用者已核对本域独立写权限。 */
|
||||
public Detail detailForWrite(Actor actor,Source source,OwnedBusinessScope scope) {
|
||||
eligible(source,scope);var ids=List.of(source.getId());var cargo=repository.cargo(ids);
|
||||
var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor,scope);
|
||||
var original=dispatch.detail(new DispatchContracts.Actor(actor.operatorId(),ActorMode.PLATFORM,null),source.getDispatchNo());
|
||||
return new Detail(row,original,cargo.stream().map(c->new Cargo(c.getSourceKey(),c.getCustomerId(),c.getCustomerName(),
|
||||
c.getOrderNo(),c.getInboundCode(),c.getPalletNo(),projection.destinationKey(c),String.valueOf(c.getPackageCount()))).toList(),
|
||||
@@ -64,23 +80,37 @@ public class OwnedBusinessQueryService {
|
||||
/** 缺少历史归属必须明确拒绝,不能按当前车辆主档降级猜测。 */
|
||||
public Source requireOwned(String number) {
|
||||
if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号");
|
||||
var source=repository.source(number);if(source==null)throw missing();eligible(source);return source;
|
||||
var source=repository.source(number);if(source==null)throw missing();eligible(source,OwnedBusinessScope.OWNED);return source;
|
||||
}
|
||||
/** 共享附加表仍按原装车归属隔离,不接受浏览器业务域参数。 */
|
||||
public Source requireScoped(String number,OwnedBusinessScope scope) {
|
||||
if(scope==OwnedBusinessScope.OWNED)return requireOwned(number);
|
||||
if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号");
|
||||
var source=repository.source(number);if(source==null)throw scope.missing();eligible(source,scope);return source;
|
||||
}
|
||||
/** 阶段、真实装车hash及车辆快照须与捕获证据一致。 */
|
||||
private void eligible(Source source) {
|
||||
if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入自有业务");
|
||||
if(!"OWNED".equals(source.getOwnedVehicleOwnership()))throw missing();
|
||||
private void eligible(Source source,OwnedBusinessScope scope) {
|
||||
if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务");
|
||||
if(!scope.getOwnership().equals(source.getOwnedVehicleOwnership()))throw scope.missing();
|
||||
if(!"ACTIVE".equals(source.getStatus()) || source.getStage()==null || !Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(source.getStage())
|
||||
|| source.getLoadingCompletedAt()==null || source.getLoadingConfirmationJson()==null)throw conflict("本单尚无合法装车完成事实");
|
||||
var resource=codec.read(source.getResourcesJson(),DispatchContracts.Resources.class).vehicle();
|
||||
if(resource==null || resource.vehicleId()==null || !Objects.equals(source.getOwnedVehicleId(),resource.vehicleId())
|
||||
|| !"MASTER".equals(source.getOwnedResourceMode()) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash())
|
||||
boolean master=resource!=null && resource.mode()!=null && "MASTER".equals(resource.mode().name()) && "MASTER".equals(source.getOwnedResourceMode())
|
||||
&& resource.vehicleId()!=null && Objects.equals(source.getOwnedVehicleId(),resource.vehicleId());
|
||||
boolean external=scope==OwnedBusinessScope.OUTSOURCED && resource!=null && resource.mode()!=null && "EXTERNAL".equals(resource.mode().name())
|
||||
&& "EXTERNAL".equals(source.getOwnedResourceMode()) && resource.vehicleId()==null && source.getOwnedVehicleId()==null;
|
||||
if((!master && !external) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash())
|
||||
|| !Objects.equals(source.getOwnedLoadingCompletedAt(),source.getLoadingCompletedAt()))throw conflict("车辆归属与原装车事实不一致");
|
||||
}
|
||||
/** 操作记录只返回真实附加版本,不制造初始示例事件。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public HistoryPage history(Actor actor,String number,Integer requestedPage,Integer requestedSize) {
|
||||
codec.require(actor,LIST);var source=requireOwned(number);int page=page(requestedPage),size=size(requestedSize);
|
||||
return history(actor,number,requestedPage,requestedSize,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 两类业务复用历史分页,但每次读取先核对权限和冻结归属。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public HistoryPage history(Actor actor,String number,Integer requestedPage,Integer requestedSize,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.listPermission());var source=requireScoped(number,scope);int page=page(requestedPage),size=size(requestedSize);
|
||||
int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");}
|
||||
return new HistoryPage(repository.history(source.getId(),offset,size).stream().map(h->{
|
||||
var snapshot="OPERATIONS".equals(h.getEventSource())?codec.read(h.getSnapshotJson(),Snapshot.class):null;
|
||||
|
||||
+22
-8
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
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.model.OwnedBusinessRows.Record;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
|
||||
@@ -30,9 +31,16 @@ public class OwnedBusinessRecordService {
|
||||
/** 整车只保留一份可更正/清空的money,实际油耗为手工L/100km。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public Detail save(Actor actor,String number,SaveOperations request) {
|
||||
codec.require(actor,EDIT);if(request==null || request.operations()==null)throw bad("运营登记内容缺失");
|
||||
return save(actor,number,request,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 共享CAS与命令账本;固定外配域不允许保存里程或油耗。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public Detail save(Actor actor,String number,SaveOperations request,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.editPermission());if(request==null || request.operations()==null)throw bad("运营登记内容缺失");
|
||||
if(scope==OwnedBusinessScope.OUTSOURCED && (request.operations().actualDistanceMiles()!=null || request.operations().fuelConsumptionLitresPer100Km()!=null))
|
||||
throw bad("外配业务不支持里程或油耗登记");
|
||||
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());
|
||||
var source=queries.requireOwned(number);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||
var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||
if(previous!=null){commands.same(previous,"SAVE_OPERATIONS",version,request);return result(previous);}
|
||||
if(request.reason()!=null && request.reason().length()>1000)throw bad("操作说明过长");
|
||||
var ops=normalize(source,request.operations());var row=lock(source,actor);
|
||||
@@ -42,13 +50,18 @@ public class OwnedBusinessRecordService {
|
||||
row.setCurrency(ops.money()==null?null:ops.money().currency());row.setTotalAmount(ops.money()==null?null:new BigDecimal(ops.money().amount()));
|
||||
row.setActualDistanceMiles(decimal(ops.actualDistanceMiles()));row.setFuelConsumptionLitresPer100Km(decimal(ops.fuelConsumptionLitresPer100Km()));
|
||||
row.setArrivalsJson(codec.write(ops.arrivals()));
|
||||
return commit(source,row,actor,version,"SAVE_OPERATIONS",request.idempotencyKey(),request,request.reason());
|
||||
return commit(source,row,actor,version,"SAVE_OPERATIONS",request.idempotencyKey(),request,request.reason(),scope);
|
||||
}
|
||||
/** File元数据已经在HTTP外层校验,绑定事务只处理本页行锁。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,Photo verified) {
|
||||
codec.require(actor,UPLOAD);if(request==null || slot==null || verified==null)throw bad("照片绑定内容缺失");
|
||||
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());var source=queries.requireOwned(number);
|
||||
return bind(actor,number,slot,request,verified,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 外配五框在事务内再次校验,不能靠调用共享写服务绕过。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,Photo verified,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.uploadPermission());if(request==null || slot==null || verified==null)throw bad("照片绑定内容缺失");scope.requireSlot(slot);
|
||||
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);
|
||||
Object identity=new PhotoBinding(slot,request);
|
||||
var old=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||
if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);}
|
||||
@@ -60,7 +73,7 @@ public class OwnedBusinessRecordService {
|
||||
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())));
|
||||
return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot));
|
||||
return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot),scope);
|
||||
}
|
||||
/** 操作记录使用准确中文槽位,不向用户展示内部枚举。 */
|
||||
private String photoReason(PhotoSlot slot) {
|
||||
@@ -104,13 +117,14 @@ public class OwnedBusinessRecordService {
|
||||
/** 版本变化必须由浏览器刷新,不能覆盖其他操作者的登记。 */
|
||||
private void requireVersion(Record row,long expected){if(row.getRecordVersion()==null || row.getRecordVersion()!=expected)throw conflict("运营记录版本已变化,请刷新后重试");}
|
||||
/** 只保存附加版本和不可变历史,真实主单读取失败也必须回滚。 */
|
||||
private Detail commit(Source source,Record row,Actor actor,long expected,String action,String key,Object request,String reason) {
|
||||
private Detail commit(Source source,Record row,Actor actor,long expected,String action,String key,Object request,String reason,OwnedBusinessScope scope) {
|
||||
row.setRecordVersion(Math.addExact(expected,1));row.setUpdatedBy(actor.operatorId());row.setUpdatedAt(codec.utc(Instant.now()));
|
||||
if(repository.update(row,expected)!=1)throw conflict("运营记录版本竞争失败");
|
||||
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(source.getId());history.setRecordVersion(row.getRecordVersion());
|
||||
history.setAction(action);history.setOperatorId(actor.operatorId());history.setReason(reason);history.setOccurredAt(row.getUpdatedAt());
|
||||
history.setSnapshotJson(codec.write(new Snapshot(projection.operations(row),projection.photos(row),actor.operatorName())));repository.history(history);
|
||||
var result=queries.detailForWrite(actor,source);commands.success(source.getId(),actor,key,action,expected,request,result);return result;
|
||||
var result=scope==OwnedBusinessScope.OWNED?queries.detailForWrite(actor,source):queries.detailForWrite(actor,source,scope);
|
||||
commands.success(source.getId(),actor,key,action,expected,request,result);return result;
|
||||
}
|
||||
/** 重放返回原成功结果,不能从现在的主单/附加版本重算。 */
|
||||
private Detail result(OwnedBusinessRows.Command command) {
|
||||
|
||||
+6
-1
@@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.JavaType;
|
||||
@@ -24,9 +25,13 @@ public class OwnedBusinessSupport {
|
||||
public OwnedBusinessSupport(ObjectMapper json){this.json=json;}
|
||||
/** 平台身份和操作者均取可信会话,不接收浏览器归属参数。 */
|
||||
public Actor actor(AuthenticatedUser user) {
|
||||
return actor(user,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 仅错误文案按固定入口区分,权限仍原样取会话,绝不补造角色。 */
|
||||
public Actor actor(AuthenticatedUser user,OwnedBusinessScope scope) {
|
||||
if(user==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可管理自有业务");
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可管理"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务");
|
||||
String operator=user.userId()==null?user.username():user.userId().toString();
|
||||
if(operator==null || operator.isBlank())throw new ResponseStatusException(HttpStatus.FORBIDDEN,"缺少有效操作账号");
|
||||
String name=user.displayName()==null || user.displayName().isBlank()?user.username():user.displayName();
|
||||
|
||||
Reference in New Issue
Block a user