Compare commits

...
46 changed files with 3065 additions and 44 deletions
+80 -1
View File
@@ -4882,4 +4882,83 @@ CREATE TABLE ll_fleet_pickup_history (
KEY idx_pickup_history_trip(trip_no,id) KEY idx_pickup_history_trip(trip_no,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 228 AS initialized_tables; CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership (
dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键',
resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL',
vehicle_id BIGINT NULL COMMENT '真实车辆档案主键,临时外配为空',
vehicle_ownership VARCHAR(16) NOT NULL COMMENT '装车完成时车辆归属OWNED或DISPATCHED',
vehicle_record_version BIGINT NULL COMMENT '捕获归属时真实车辆档案版本',
ownership_captured_at DATETIME(3) NOT NULL COMMENT '归属捕获UTC时间',
loading_completion_hash VARCHAR(128) NOT NULL COMMENT '原排车装车确认事实摘要',
loading_completed_at DATETIME(3) NOT NULL COMMENT '原排车实际装车完成UTC时间',
PRIMARY KEY (dispatch_id),
KEY idx_owned_business_vehicle (vehicle_ownership,vehicle_id),
CONSTRAINT fk_owned_business_ownership_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_ownership CHECK (vehicle_ownership IN ('OWNED','DISPATCHED')),
CONSTRAINT ck_owned_business_resource_mode CHECK (
(resource_mode='MASTER' AND vehicle_id IS NOT NULL AND vehicle_record_version IS NOT NULL AND vehicle_record_version>=0)
OR (resource_mode='EXTERNAL' AND vehicle_id IS NULL AND vehicle_record_version IS NULL AND vehicle_ownership='DISPATCHED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车装车完成时的车辆归属证据';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record (
id BIGINT NOT NULL COMMENT '附加运营记录内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
record_version BIGINT NOT NULL COMMENT '附加运营记录并发版本,独立于原排车版本',
currency VARCHAR(3) NULL COMMENT '整车运营金额币种CNY或USD,未登记为空',
total_amount DECIMAL(18,2) NULL COMMENT '人工登记整车总金额,不自动形成账务费用',
actual_distance_miles DECIMAL(12,3) NULL COMMENT '实际行驶英里数,未登记为空',
fuel_consumption_litres_per_100km DECIMAL(12,3) NULL COMMENT '人工登记油耗率,单位升每百公里',
arrivals_json JSON NOT NULL COMMENT '按原目的地键记录的实际到达UTC时间',
photos_json JSON NOT NULL COMMENT '七个照片框当前已绑定文件的分类快照',
created_by VARCHAR(64) NOT NULL COMMENT '首次登记操作人标识',
updated_by VARCHAR(64) NOT NULL COMMENT '最近登记操作人标识',
created_at DATETIME(3) NOT NULL COMMENT '首次登记UTC时间',
updated_at DATETIME(3) NOT NULL COMMENT '最近登记UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_owned_business_record_dispatch (dispatch_id),
CONSTRAINT fk_owned_business_record_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_record_version CHECK (record_version>=0),
CONSTRAINT ck_owned_business_money CHECK (
(currency IS NULL AND total_amount IS NULL)
OR (currency IS NOT NULL AND currency IN ('CNY','USD') AND total_amount IS NOT NULL AND total_amount>=0)),
CONSTRAINT ck_owned_business_distance CHECK (actual_distance_miles IS NULL OR actual_distance_miles>=0),
CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command (
id BIGINT NOT NULL COMMENT '附加运营命令内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作人标识',
idempotency_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '同一原排车内唯一请求键,不按操作者分组',
action VARCHAR(32) NOT NULL COMMENT '运营登记或照片上传绑定动作',
expected_version BIGINT NULL COMMENT '原请求附加版本,纯文件上传可为空',
request_hash CHAR(64) NOT NULL COMMENT '原操作者动作版本与完整请求摘要',
request_json JSON NOT NULL COMMENT '不含文件内容或凭据的完整原请求',
status VARCHAR(24) NOT NULL COMMENT 'PENDING或SUCCEEDED或REJECTED或UNKNOWN_RESULT',
result_json JSON NULL COMMENT '原命令已知成功结果或明确拒绝事实',
error_code VARCHAR(64) NULL COMMENT '明确拒绝或未知结果诊断码',
created_at DATETIME(3) NOT NULL COMMENT '命令意图创建UTC时间',
updated_at DATETIME(3) NOT NULL COMMENT '命令结果更新UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_owned_business_command_key (dispatch_id,idempotency_key),
CONSTRAINT fk_owned_business_command_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_command_version CHECK (expected_version IS NULL OR expected_version>=0),
CONSTRAINT ck_owned_business_command_status CHECK (status IN ('PENDING','SUCCEEDED','REJECTED','UNKNOWN_RESULT'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车附加运营写命令及可恢复结果';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
id BIGINT NOT NULL COMMENT '附加运营历史内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
record_version BIGINT NOT NULL COMMENT '成功写入后的附加记录版本',
action VARCHAR(32) NOT NULL COMMENT '运营登记或照片绑定动作',
operator_id VARCHAR(64) NOT NULL COMMENT '真实操作人标识',
reason VARCHAR(1000) NULL COMMENT '人工填写的登记或更正说明',
snapshot_json JSON NOT NULL COMMENT '操作后附加运营信息与分类照片不可变快照',
occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_owned_business_history_version (dispatch_id,record_version),
CONSTRAINT fk_owned_business_history_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_history_version CHECK (record_version>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 232 AS initialized_tables;
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.dispatch.fleet;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.dispatch.port.DispatchViews;
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService;
import org.springframework.stereotype.Component;
/** 只读委托既有排车应用服务,运营域不引用主流程实现或持久化实体。 */
@Component
public class DispatchDetailReadAdapter implements DispatchDetailReader {
private final DispatchAggregateService dispatch;
/** 复用唯一原排车详情提供者,不建立第二套查询规则。 */
public DispatchDetailReadAdapter(DispatchAggregateService dispatch) {
this.dispatch = dispatch;
}
/** 原单资格与可见性仍由原应用服务核对。 */
@Override
public DispatchViews.Detail detail(DispatchContracts.Actor actor, String dispatchNo) {
return dispatch.detail(actor, dispatchNo);
}
}
@@ -261,7 +261,8 @@ public interface DispatchCandidateMapper {
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" + "<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" + "<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" + "<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
"AND pos.hold_packages &gt; 0 " + // 未绑定实托的位置不是可选运输来源;完整申请中的缺失组成仍由提供者返回阻断原因。
"AND pos.hold_packages &gt; 0 AND pos.pallet_id &gt; 0 " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " +
"ON existing_dispatch.id=di.dispatch_order_id " + "ON existing_dispatch.id=di.dispatch_order_id " +
"WHERE di.customer_id=a.customer_id AND di.source_stage='RELABEL' AND di.order_refs=a.relabel_no " + "WHERE di.customer_id=a.customer_id AND di.source_stage='RELABEL' AND di.order_refs=a.relabel_no " +
@@ -0,0 +1,7 @@
package com.baiyue.lastleg.dispatch.port;
/** 原排车详情的只读应用端口,供运营视图组合真实履约事实。 */
public interface DispatchDetailReader {
/** 沿原排车读取及身份守卫,不创建单据、不推进状态。 */
DispatchViews.Detail detail(DispatchContracts.Actor actor, String dispatchNo);
}
@@ -10,6 +10,7 @@ import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.port.*; import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
import com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture;
import com.baiyue.lastleg.fleet.trip.FleetExecutionModels; import com.baiyue.lastleg.fleet.trip.FleetExecutionModels;
import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService; import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
@@ -47,6 +48,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
private final ObjectMapper json; private final ObjectMapper json;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway evidence; @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway evidence;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository executionReads; @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository executionReads;
@jakarta.annotation.Resource private OwnedBusinessOwnershipCapture ownedBusinessOwnership;
/** 领域编排只访问Repository和提供者应用入口。 */ /** 领域编排只访问Repository和提供者应用入口。 */
public DispatchAggregateService(DispatchAggregateRepository repository, DispatchSourceRepository sources, public DispatchAggregateService(DispatchAggregateRepository repository, DispatchSourceRepository sources,
@@ -522,6 +524,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
fleet.confirmDispatchLoad(number,actor.operatorId(),preview.manifestHash(),cargo,measurements.stream().map(m->new FleetExecutionModels.Measurement(m.warehousePalletId().toString(),m.palletSpaces(),m.weightKg(),m.volumeCbm(),m.deliveryIndex())).toList(),request.remark()); fleet.confirmDispatchLoad(number,actor.operatorId(),preview.manifestHash(),cargo,measurements.stream().map(m->new FleetExecutionModels.Measurement(m.warehousePalletId().toString(),m.palletSpaces(),m.weightKg(),m.volumeCbm(),m.deliveryIndex())).toList(),request.remark());
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var confirmed=new ConfirmedLoad(preview.manifestHash(),measurements,actor.operatorId(),at); var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var confirmed=new ConfirmedLoad(preview.manifestHash(),measurements,actor.operatorId(),at);
row.setLoadingConfirmationJson(write(confirmed));row.setLoadingCompletionHash(hashes.source(confirmed));row.setLoadingCompletedAt(utc(at));row.setStage(Stage.AFTER_LOADING.name()); row.setLoadingConfirmationJson(write(confirmed));row.setLoadingCompletionHash(hashes.source(confirmed));row.setLoadingCompletedAt(utc(at));row.setStage(Stage.AFTER_LOADING.name());
// 同原装车事务保存车辆归属证据;后续档案变化不能改写本趟自有/外配分类。
// 原命令重放已在方法入口返回,不重复捕获,也不在GET或旧单上补造历史。
ownedBusinessOwnership.capture(row.getId(),context.resources().vehicle().vehicleId(),
context.resources().vehicle().mode().name(),row.getLoadingCompletionHash(),at);
return saveCommand(row,actor,identity,request.remark(),new DispatchResults.Loaded(row.getLoadingCompletionHash(),at,confirmed)); return saveCommand(row,actor,identity,request.remark(),new DispatchResults.Loaded(row.getLoadingCompletionHash(),at,confirmed));
} }
@@ -90,7 +90,9 @@ public class DispatchService {
if(facts==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"实托完整来源快照缺失"); if(facts==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"实托完整来源快照缺失");
reasons.addAll(facts.blockReasons()); reasons.addAll(facts.blockReasons());
if(!facts.complete() && facts.blockReasons().isEmpty())reasons.add("实托实际绑定组成未核齐"); if(!facts.complete() && facts.blockReasons().isEmpty())reasons.add("实托实际绑定组成未核齐");
facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).filter(Objects::nonNull).forEach(pallets::add); // 缺失的实托身份保留在事实与阻断原因中,不能拿零值扩组或传给严格的实托查询入口。
facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId)
.filter(id->id!=null && id>0).forEach(pallets::add);
for(var app:facts.applications())applications.put(app.relabelNo(),app.customerId()); for(var app:facts.applications())applications.put(app.relabelNo(),app.customerId());
var codes=rows.values().stream().map(DispatchPalletCandidate::palletNo).filter(Objects::nonNull).distinct().toList(); var codes=rows.values().stream().map(DispatchPalletCandidate::palletNo).filter(Objects::nonNull).distinct().toList();
for(var app:wholeSources.nativeApplications(seed.overseasWarehouseId(),codes))applications.put(app.relabelNo(),app.customerId()); for(var app:wholeSources.nativeApplications(seed.overseasWarehouseId(),codes))applications.put(app.relabelNo(),app.customerId());
@@ -0,0 +1,78 @@
package com.baiyue.lastleg.fleet.ownedbusiness.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
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/owned-business")
public class OwnedBusinessController {
private final OwnedBusinessQueryService queries;
private final OwnedBusinessRecordService records;
private final OwnedBusinessPhotoService photos;
private final OwnedBusinessSupport access;
/** Controller不直调Repository/Mapper,身份只取会话。 */
public OwnedBusinessController(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,OwnedBusinessPhotoService photos,OwnedBusinessSupport access) {
this.queries=queries;this.records=records;this.photos=photos;this.access=access;
}
/** 列表和完整统计一次返回。 */
@GetMapping
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
public ApiResponse<ListResult> list(@AuthenticationPrincipal AuthenticatedUser user,@Valid Query query){return ApiResponse.success(queries.list(access.actor(user),query));}
/** list权限包含独立详情。 */
@GetMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo){return ApiResponse.success(queries.detail(access.actor(user),dispatchNo));}
/** 原排车和附加历史在同一快照分页,不逐条GET。 */
@GetMapping("/{dispatchNo}/history")
@PreAuthorize("hasAuthority('platform:fleet:owned-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(queries.history(access.actor(user),dispatchNo,page,size));
}
/** 金额可以更正/清空,但不推进主单或触发财务。 */
@PutMapping("/{dispatchNo}/operations")
@PreAuthorize("hasAuthority('platform:fleet:owned-business:edit')")
public ApiResponse<Detail> save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@Valid @RequestBody SaveOperations request) {
return ApiResponse.success(records.save(access.actor(user),dispatchNo,request));
}
/** 上传仅准备File,关联保存另有独立原键。 */
@PostMapping(value="/{dispatchNo}/photos/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:fleet:owned-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(photos.upload(access.actor(user),dispatchNo,slot,idempotencyKey,file));
}
/** 绑定失败保留fileId和原请求,不重新上传。 */
@PutMapping("/{dispatchNo}/photos/{slot}")
@PreAuthorize("hasAuthority('platform:fleet:owned-business:upload')")
public ApiResponse<Detail> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) {
return ApiResponse.success(photos.bind(access.actor(user),dispatchNo,slot,request));
}
/** 附件只允许当前或精确历史引用读取。 */
@GetMapping("/{dispatchNo}/photos/{slot}/files/{fileId}/content")
@PreAuthorize("hasAuthority('platform:fleet:owned-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=photos.content(access.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:owned-business:list')")
public ApiResponse<Command> command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
return ApiResponse.success(photos.command(access.actor(user),dispatchNo,idempotencyKey));
}
}
@@ -0,0 +1,86 @@
package com.baiyue.lastleg.fleet.ownedbusiness.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.time.LocalDateTime;
import java.util.Set;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
/** File保存照片,本域另行授权原排车及分类引用。 */
@Component
public class HttpOwnedBusinessFileGateway implements OwnedBusinessFileGateway {
public static final long MAX_SIZE=10L*1024*1024;
private static final String PATH="/internal/file/v1/business-files/0/{type}/{dispatch}";
private final RestClient client;
private final String token;
/** 沿既有内部File客户端,保持外部访问域封闭。 */
public HttpOwnedBusinessFileGateway(RestClient.Builder builder,LastlegRestClientFactory factory,LastlegRestClientProperties properties,
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") String baseUrl,
@Value("${baiyue.security.internal-service-token:}") String token) {
client=factory.build(builder,baseUrl,properties.getCustomerVisitFile());this.token=token;
}
/** MIME和字节签名一起校验,File仍执行其权威校验。 */
public static void validate(Upload upload) {
if(upload==null || upload.fileName()==null || upload.fileName().isBlank() || upload.fileName().length()>255
|| upload.content()==null || upload.content().length==0 || upload.content().length>MAX_SIZE
|| !image(upload.contentType()) || !signature(upload.contentType(),upload.content()))
throw bad("照片须为非空真实JPG/PNG,最大10MiB");
}
/** 使用原图片字节,未知结果不能换键或自动重传。 */
@Override public Evidence upload(long dispatchId,PhotoSlot slot,String operatorId,Upload upload) {
validate(upload);var body=new MultipartBodyBuilder();
body.part("file",new ByteArrayResource(upload.content()){
/** 保留本轮原文件名,不生成占位证据。 */
@Override public String getFilename(){return upload.fileName();}
}).contentType(MediaType.parseMediaType(upload.contentType()));
var result=client.post().uri(PATH,slot.businessType(),dispatchId).header("X-Internal-Service-Token",token)
.header("X-Operator-Id",operatorId).contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve().body(Envelope.class);
return verified(result,dispatchId,slot,null);
}
/** 不能借另一客户、另一分类或另一排车的文件授权。 */
@Override public Evidence metadata(long dispatchId,PhotoSlot slot,long fileId) {
return verified(client.get().uri(PATH+"/{file}",slot.businessType(),dispatchId,fileId)
.header("X-Internal-Service-Token",token).retrieve().body(Envelope.class),dispatchId,slot,fileId);
}
/** 防止把错误页或其他内容冒充图片返回。 */
@Override public byte[] content(long dispatchId,PhotoSlot slot,long fileId) {
var info=metadata(dispatchId,slot,fileId);
byte[] bytes=client.get().uri(PATH+"/{file}/content",slot.businessType(),dispatchId,fileId)
.header("X-Internal-Service-Token",token).retrieve().body(byte[].class);
if(bytes==null || bytes.length!=info.sizeBytes() || !signature(info.contentType(),bytes))throw conflict("图片内容与元数据不一致");
return bytes;
}
/** 所有归属及内容元数据必须严格匹配原dispatch内部ID。 */
public static Evidence verified(Envelope response,long dispatchId,PhotoSlot slot,Long expected) {
if(response==null || !"200".equals(response.code()) || response.data()==null)throw conflict("照片服务未返回明确成功");
var m=response.data();
if(m.fileId()==null || m.fileId()<=0 || !Long.valueOf(0).equals(m.tenantId()) || !slot.businessType().equals(m.businessType())
|| !String.valueOf(dispatchId).equals(m.businessId()) || expected!=null && !expected.equals(m.fileId())
|| m.sizeBytes()==null || m.sizeBytes()<=0 || m.sizeBytes()>MAX_SIZE || !image(m.contentType())
|| m.fileName()==null || m.fileName().isBlank() || m.createdAt()==null)throw bad("照片不属于本排车或分类,或文件格式无效");
return new Evidence(m.fileId(),m.fileName(),m.contentType(),m.sizeBytes(),m.createdAt().toString());
}
/** 不放开PDF、SVG或任意image类型。 */
private static boolean image(String type){return Set.of("image/jpeg","image/png").contains(type==null?"":type);}
/** 使用真实文件头,不转换或伪造证据图片。 */
private static boolean signature(String type,byte[] bytes) {
if(bytes==null)return false;
if("image/jpeg".equals(type))return bytes.length>=3 && (bytes[0]&255)==255 && (bytes[1]&255)==216 && (bytes[2]&255)==255;
byte[] png={(byte)137,80,78,71,13,10,26,10};if(!"image/png".equals(type) || bytes.length<png.length)return false;
for(int i=0;i<png.length;i++)if(bytes[i]!=png[i])return false;return true;
}
/** 基础设施错误不反向依赖应用Service,保持单向包依赖。 */
private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);}
/** 不明确的文件结果保留为业务冲突,由上传账本记录。 */
private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);}
public record Envelope(String code,Metadata data) {}
public record Metadata(Long fileId,Long tenantId,String businessType,String businessId,String fileName,String contentType,Long sizeBytes,LocalDateTime createdAt) {}
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.fleet.ownedbusiness.gateway;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
/** 本页平台图片受控代理,不接收浏览器指定的内部主键或业务类型。 */
public interface OwnedBusinessFileGateway {
record Upload(String fileName,String contentType,byte[] content) {}
record Evidence(long fileId,String fileName,String contentType,long sizeBytes,String uploadedAt) {}
/** 同一原意图只上传一次,未知结果由账本保存。 */
Evidence upload(long dispatchId,PhotoSlot slot,String operatorId,Upload upload);
/** 核验平台、原排车、分类、文件身份和图像格式。 */
Evidence metadata(long dispatchId,PhotoSlot slot,long fileId);
/** 只代理业务层已确认当前或历史引用的内容。 */
byte[] content(long dispatchId,PhotoSlot slot,long fileId);
}
@@ -0,0 +1,88 @@
package com.baiyue.lastleg.fleet.ownedbusiness.mapper;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 本页4表唯一SQL入口;不更新排车、库存或财务表。 */
@Mapper
public interface OwnedBusinessMapper {
/** 读取已经冻结的装车归属,不查询当前车辆推断历史。 */
@Select("SELECT * FROM ll_fleet_owned_business_ownership WHERE dispatch_id=#{id}")
Ownership ownership(long id);
/** 在主装车事务保存唯一归属证据,重复交由提供者核对原事实。 */
@Insert("""
INSERT INTO ll_fleet_owned_business_ownership(dispatch_id,vehicle_id,resource_mode,vehicle_ownership,
vehicle_record_version,ownership_captured_at,loading_completion_hash,loading_completed_at)
VALUES(#{dispatchId},#{vehicleId},#{resourceMode},#{vehicleOwnership},#{vehicleRecordVersion},
#{ownershipCapturedAt},#{loadingCompletionHash},#{loadingCompletedAt})
""")
int capture(Ownership row);
/** GET不使用此方法;首个写事务建立可锁定的附加0版本行。 */
@Insert("""
INSERT INTO ll_fleet_owned_business_record(id,dispatch_id,record_version,arrivals_json,photos_json,
created_by,updated_by,created_at,updated_at) VALUES(#{id},#{dispatchId},0,'[]','[]',
#{createdBy},#{updatedBy},#{createdAt},#{updatedAt}) ON DUPLICATE KEY UPDATE dispatch_id=dispatch_id
""")
int ensure(Record row);
/** 只锁本页附加行,不反向申请车辆或派送写锁。 */
@Select("SELECT * FROM ll_fleet_owned_business_record WHERE dispatch_id=#{id} FOR UPDATE")
Record lockRecord(long id);
/** 详情只读附加信息,无记录返回虚拟0版本。 */
@Select("SELECT * FROM ll_fleet_owned_business_record WHERE dispatch_id=#{id}")
Record record(long id);
/** 页内批量读取,避免逐车补充GET和SQL。 */
@Select("<script>SELECT * FROM ll_fleet_owned_business_record WHERE dispatch_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<Record> records(@Param("ids") List<Long> ids);
/** CAS仅修改运营附加字段,不占用主单recordVersion。 */
@Update("""
UPDATE ll_fleet_owned_business_record SET record_version=#{r.recordVersion},currency=#{r.currency},
total_amount=#{r.totalAmount},actual_distance_miles=#{r.actualDistanceMiles},
fuel_consumption_litres_per_100km=#{r.fuelConsumptionLitresPer100Km},arrivals_json=#{r.arrivalsJson},
photos_json=#{r.photosJson},updated_by=#{r.updatedBy},updated_at=#{r.updatedAt}
WHERE dispatch_id=#{r.dispatchId} AND record_version=#{expected}
""")
int update(@Param("r") Record row,@Param("expected") long expected);
/** 命令键全车唯一,操作者在服务端另行核验。 */
@Select("SELECT * FROM ll_fleet_owned_business_command WHERE dispatch_id=#{id} AND idempotency_key=#{key}")
Command command(@Param("id") long id,@Param("key") String key);
/** 完整意图在文件HTTP之前单独事务提交。 */
@Insert("""
INSERT INTO ll_fleet_owned_business_command(id,dispatch_id,operator_id,idempotency_key,action,
expected_version,request_hash,request_json,status,result_json,error_code,created_at,updated_at)
VALUES(#{id},#{dispatchId},#{operatorId},#{idempotencyKey},#{action},#{expectedVersion},
#{requestHash},#{requestJson},#{status},#{resultJson},#{errorCode},#{createdAt},#{updatedAt})
""")
int insertCommand(Command row);
/** 上传只完成原待定意图,不能把未知结果当新命令重做。 */
@Update("""
UPDATE ll_fleet_owned_business_command SET status=#{status},result_json=#{resultJson},
error_code=#{errorCode},updated_at=#{updatedAt} WHERE id=#{id} AND status='PENDING'
""")
int finishUpload(Command row);
/** 附加写与不可变历史同事务,版本不能重复。 */
@Insert("""
INSERT INTO ll_fleet_owned_business_history(id,dispatch_id,record_version,action,operator_id,
reason,snapshot_json,occurred_at) VALUES(#{id},#{dispatchId},#{recordVersion},#{action},#{operatorId},
#{reason},#{snapshotJson},#{occurredAt})
""")
int insertHistory(History row);
/** 历史按本页版本顺序分页。 */
@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}
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}
) 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);
/** 历史总数不取当前页长度。 */
@Select("SELECT (SELECT COUNT(*) FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id})+(SELECT COUNT(*) FROM ll_dispatch_history WHERE dispatch_id=#{id})")
int historyCount(long id);
/** 历史文件只能从指定真实历史快照读取。 */
@Select("SELECT * FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id} AND record_version=#{version}")
History revision(@Param("id") long id,@Param("version") long version);
}
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.fleet.ownedbusiness.mapper;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 在原排车事实之上做自有运营分页,不复制或推进履约模型。 */
@Mapper
public interface OwnedBusinessQueryMapper {
String SOURCE="""
SELECT d.id,d.dispatch_no,d.status,d.stage,d.record_version,d.overseas_warehouse_id,
d.overseas_warehouse_code,d.overseas_warehouse_name,d.planned_departure_at,d.loading_completed_at,
d.departed_at,d.delivery_completed_at,d.loading_completion_hash,d.loading_confirmation_json,d.resources_json,
o.vehicle_id AS owned_vehicle_id,o.resource_mode AS owned_resource_mode,
o.vehicle_ownership AS owned_vehicle_ownership,o.vehicle_record_version AS owned_vehicle_record_version,
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
""";
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
<if test='q.from != null'> AND d.planned_departure_at &gt;= #{q.from}</if>
<if test='q.to != null'> AND d.planned_departure_at &lt; #{q.to}</if>
<if test='q.stage != null'> AND d.stage=#{q.stage}</if>
<if test='q.warehouseId != null'> AND d.overseas_warehouse_id=#{q.warehouseId}</if>
<if test='q.keyword != null'> AND (d.dispatch_no LIKE CONCAT('%',#{q.keyword},'%')
OR d.vehicle_plate LIKE CONCAT('%',#{q.keyword},'%') OR EXISTS(SELECT 1 FROM ll_dispatch_item i
WHERE i.dispatch_order_id=d.id AND (i.destination_warehouse_code LIKE CONCAT('%',#{q.keyword},'%')
OR i.fba_warehouse_code LIKE CONCAT('%',#{q.keyword},'%')
OR i.destination_warehouse_name LIKE CONCAT('%',#{q.keyword},'%'))))</if>
""";
/** 只返回当前完整条件下的一车一行。 */
@Select("<script>"+SOURCE+" WHERE "+FILTER+" ORDER BY d.planned_departure_at DESC,d.id DESC LIMIT #{q.limit} OFFSET #{q.offset}</script>")
List<Source> page(@Param("q") Search query);
/** 同一过滤片段不带分页,不能拿当前页长度冒充统计。 */
@Select("<script>SELECT d.stage,COUNT(*) AS quantity FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id WHERE "+FILTER+" GROUP BY d.stage</script>")
List<StageCount> summary(@Param("q") Search query);
/** 先读原主单和可空归属,以明确区别无快照与不存在。 */
@Select(SOURCE+" WHERE d.dispatch_no=#{number}")
Source find(String number);
/** 页内全部货物一次读取,各客户保持原归属。 */
@Select("<script>SELECT id,dispatch_order_id,warehouse_pallet_id,customer_id,customer_name,source_key,order_no,inbound_code,pallet_no,destination_type,destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count FROM ll_dispatch_item WHERE dispatch_order_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY dispatch_order_id,id</script>")
List<CargoRow> cargo(@Param("ids") List<Long> ids);
/** 只聚合真实FBA分配,目的仓与数量保持原事实,不按客户重复整车。 */
@Select("""
<script>SELECT a.dispatch_id,a.destination_key,p.appointment_no,p.confirmation_no,
p.appointment_start_utc AS start_utc,SUM(a.package_count) AS allocated_packages
FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_item i
ON i.id=a.dispatch_item_id AND i.dispatch_order_id=a.dispatch_id AND i.customer_id=a.customer_id
JOIN ll_inbound_fba_appointment p ON a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id)
WHERE a.dispatch_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
GROUP BY a.dispatch_id,a.destination_key,p.appointment_no,p.confirmation_no,p.appointment_start_utc
ORDER BY a.dispatch_id,a.destination_key,p.appointment_no</script>
""")
List<Batch> batches(@Param("ids") List<Long> ids);
}
@@ -0,0 +1,64 @@
package com.baiyue.lastleg.fleet.ownedbusiness.model;
import com.baiyue.lastleg.dispatch.port.DispatchViews;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;
import java.util.Set;
/** 原排车只读投影和附加信息,不公开第二套履约命令。 */
public final class OwnedBusinessModels {
/** 工具容器不允许实例化。 */
private OwnedBusinessModels() {}
public static final String LIST="platform:fleet:owned-business:list";
public static final String EDIT="platform:fleet:owned-business:edit";
public static final String UPLOAD="platform:fleet:owned-business:upload";
public enum Stage { AFTER_LOADING, DELIVERING, DELIVERED }
public enum PhotoSlot {
LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT, FUEL_START, FUEL_END;
/** 图片框分类不表示司机审核或真实派送签收。 */
public String category() { return name().startsWith("FUEL_")?"FUEL":"LOADING"; }
/** 文件类型与租户归属由本域固定,浏览器不能指定。 */
public String businessType() { return category().equals("FUEL")?"OVERSEAS_DISPATCH_FUEL_PHOTO":"OVERSEAS_DISPATCH_LOADING_PHOTO"; }
}
public record Actor(String operatorId,String operatorName,Set<String> permissions) {}
public record Query(@Min(1) Integer page,@Min(1) @Max(200) Integer size,
String plannedDepartureFrom,String plannedDepartureTo,@Size(max=128) String keyword,
Stage stage,@Size(max=64) String warehouseId) {}
public record Money(@NotBlank @Pattern(regexp="CNY|USD") String currency,
@NotBlank @Pattern(regexp="(0|[1-9][0-9]{0,15})(\\.[0-9]{1,2})?") String amount) {}
public record Arrival(@NotBlank @Size(max=256) String destinationKey,String actualArrivedAt) {}
public record Operations(@Valid Money money,String actualDistanceMiles,
String fuelConsumptionLitresPer100Km,@NotNull List<@NotNull @Valid Arrival> arrivals) {}
public record SaveOperations(@NotBlank String recordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotNull @Valid Operations operations,
@Size(max=1000) String reason) {}
public record BindPhoto(@NotBlank String recordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {}
public record Gate(boolean allowed,String blockReason) {}
public record Actions(Gate edit,Gate upload) {}
public record Origin(String warehouseId,String warehouseCode,String warehouseName) {}
public record Vehicle(String vehicleId,String plate,String ownership) {}
public record Driver(String name,String phone) {}
public record Appointment(String appointmentNo,String confirmationNo,String scheduledAt,String allocatedPackages) {}
public record Destination(String destinationKey,String warehouseCode,String warehouseName,String address,
List<Appointment> appointments,String actualArrivedAt) {}
public record Photo(PhotoSlot slot,String category,String fileId,String fileName,String contentType,
String sizeBytes,String uploadedAt,String savedAt) {}
public record PhotoSummary(int loadingCount,int fuelCount) {}
public record Row(String dispatchNo,Stage stage,String plannedDepartureAt,String loadingCompletedAt,
String departedAt,String deliveryCompletedAt,Origin origin,Vehicle vehicle,Driver driver,
List<Destination> destinations,Integer actualPalletCount,Operations operations,String unitPrice,
String recordVersion,String dispatchRecordVersion,PhotoSummary photoSummary,Actions actions,List<String> sourceIssues) {}
public record Summary(int dispatchCount,int afterLoadingCount,int deliveringCount,int deliveredCount) {}
public record ListResult(List<Row> records,int page,int size,int total,Summary summary) {}
public record Cargo(String sourceKey,String customerId,String customerName,String orderNo,
String inboundCode,String palletNo,String destinationKey,String packages) {}
public record Detail(Row row,DispatchViews.Detail dispatch,List<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) {}
public record Snapshot(Operations operations,List<Photo> photos,String operatorName) {}
}
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.fleet.ownedbusiness.model;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 仅供本包持久化,不向浏览器输出内部主键。 */
public final class OwnedBusinessRows {
/** 持久化容器不允许实例化。 */
private OwnedBusinessRows() {}
@Data public static class Ownership {
private Long dispatchId,vehicleId,vehicleRecordVersion;
private String resourceMode,vehicleOwnership,loadingCompletionHash;
private LocalDateTime ownershipCapturedAt,loadingCompletedAt;
}
@Data public static class Source {
private Long id,recordVersion;
private String dispatchNo,status,stage,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName;
private String loadingCompletionHash,loadingConfirmationJson,resourcesJson;
private LocalDateTime plannedDepartureAt,loadingCompletedAt,departedAt,deliveryCompletedAt;
private String ownedResourceMode,ownedVehicleOwnership,ownedLoadingCompletionHash;
private Long ownedVehicleId,ownedVehicleRecordVersion;
private LocalDateTime ownedLoadingCompletedAt;
}
/** 原表货物只读字段;不是可写排车实体,不复制来源状态机。 */
@Data public static class CargoRow {
private Long id,dispatchOrderId,warehousePalletId;
private String customerId,customerName,sourceKey,orderNo,inboundCode,palletNo;
private String destinationType,destinationWarehouseCode,destinationWarehouseName,fbaWarehouseCode,deliveryAddress,postalCode;
private Integer packageCount;
}
@Data public static class Record {
private Long id,dispatchId,recordVersion;
private String currency,arrivalsJson,photosJson,createdBy,updatedBy;
private BigDecimal totalAmount,actualDistanceMiles,fuelConsumptionLitresPer100Km;
private LocalDateTime createdAt,updatedAt;
}
@Data public static class Command {
private Long id,dispatchId,expectedVersion;
private String operatorId,idempotencyKey,action,requestHash,requestJson,status,resultJson,errorCode;
private LocalDateTime createdAt,updatedAt;
}
@Data public static class History {
private Long id,dispatchId,recordVersion,dispatchRecordVersion;
private String eventSource,action,operatorId,reason,snapshotJson;
private LocalDateTime occurredAt;
}
@Data public static class Batch {
private Long dispatchId;
private String destinationKey,appointmentNo,confirmationNo;
private LocalDateTime startUtc;
private Long allocatedPackages;
}
public record Search(int offset,int limit,String keyword,String warehouseId,String stage,
LocalDateTime from,LocalDateTime to) {}
@Data public static class StageCount { private String stage;private Long quantity; }
}
@@ -0,0 +1,10 @@
package com.baiyue.lastleg.fleet.ownedbusiness.port;
import java.time.Instant;
/** 装车主事务唯一捕获车辆归属,不创建第二套履约状态。 */
public interface OwnedBusinessOwnershipCapture {
/** 主流程已稳定车辆资格,仅捕获原装车时点的不可变归属。 */
void capture(long dispatchId, Long vehicleId, String resourceMode,
String loadingCompletionHash, Instant loadingCompletedAt);
}
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.fleet.ownedbusiness.repository;
import com.baiyue.lastleg.fleet.ownedbusiness.mapper.OwnedBusinessMapper;
import com.baiyue.lastleg.fleet.ownedbusiness.mapper.OwnedBusinessQueryMapper;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 只封装本页Mapper,关系库访问不使用JdbcTemplate或直连JDBC。 */
@Repository
public class OwnedBusinessRepository {
private final OwnedBusinessMapper records;
private final OwnedBusinessQueryMapper queries;
/** 注入唯一SQL提供者,Service不能直接访问Mapper。 */
public OwnedBusinessRepository(OwnedBusinessMapper records,OwnedBusinessQueryMapper queries){this.records=records;this.queries=queries;}
/** 原归属已捕获后不按当前车辆回算。 */
public Ownership ownership(long id){return records.ownership(id);}
/** 归属与主装车事务共同提交。 */
public void capture(Ownership row){records.capture(row);}
/** 服务端整车分页。 */
public List<Source> page(Search query){return queries.page(query);}
/** 与分页相同过滤的完整统计。 */
public List<StageCount> summary(Search query){return queries.summary(query);}
/** 读取唯一原排车身份及归属证据。 */
public Source source(String number){return queries.find(number);}
/** 页内货物批量查询。 */
public List<CargoRow> cargo(List<Long> ids){return ids.isEmpty()?List.of():queries.cargo(ids);}
/** 页内实际预约分配批量查询。 */
public List<Batch> batches(List<Long> ids){return ids.isEmpty()?List.of():queries.batches(ids);}
/** GET只读,无记录保留虚拟0版本。 */
public Record record(long id){return records.record(id);}
/** 页内附加信息批量读取。 */
public List<Record> records(List<Long> ids){return ids.isEmpty()?List.of():records.records(ids);}
/** 首个写事务建立行,并按dispatch唯一键串行。 */
public Record lock(Record seed){records.ensure(seed);return records.lockRecord(seed.getDispatchId());}
/** 只CAS修改附加信息。 */
public int update(Record row,long expected){return records.update(row,expected);}
/** 命令先按全车请求键定位,再在服务端检查原操作者。 */
public Command command(long id,String key){return records.command(id,key);}
/** 命令与附加版本同事务保存,或作为独立上传意图持久化。 */
public void command(Command row){records.insertCommand(row);}
/** 上传结果只完成原待定意图。 */
public int finishUpload(Command row){return records.finishUpload(row);}
/** 同一附加版本只记录一次不可变历史。 */
public void history(History row){records.insertHistory(row);}
/** 历史总数只取原dispatch。 */
public int historyCount(long id){return records.historyCount(id);}
/** 历史采用稳定版本倒序。 */
public List<History> history(long id,int offset,int size){return records.history(id,offset,size);}
/** 下载历史附件时精确定位原版本。 */
public History revision(long id,long version){return records.revision(id,version);}
}
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Command;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.time.Instant;
import java.util.Objects;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
/** 附加操作与文件准备共用二元唯一账本,不允许异账号复用键。 */
@Service
public class OwnedBusinessCommandService {
private final OwnedBusinessRepository repository;
private final OwnedBusinessSupport codec;
/** 文件意图独立短事务,不持业务行锁跨HTTP。 */
public OwnedBusinessCommandService(OwnedBusinessRepository repository,OwnedBusinessSupport codec){this.repository=repository;this.codec=codec;}
public record Reservation(Command command,boolean created) {}
/** 查找原命令时先核操作者,不披露别人的原载荷。 */
public Command existing(long dispatchId,String key,Actor actor) {
var row=repository.command(dispatchId,key);
if(row!=null && !Objects.equals(row.getOperatorId(),actor.operatorId()))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"该请求键不属于当前操作账号");
return row;
}
/** 原action、版本和完整规范请求必须一致,不能用当前版本重造旧意图。 */
public void same(Command row,String action,Long version,Object request) {
if(!Objects.equals(row.getAction(),action) || !Objects.equals(row.getExpectedVersion(),version)
|| !Objects.equals(row.getRequestHash(),codec.hash(java.util.Arrays.asList(row.getOperatorId(),action,version,request))))throw conflict("相同请求键的操作、版本或内容不同");
}
/** 完整命令实体只用于本页,不保存浏览器凭据。 */
public Command create(long id,Actor actor,String key,String action,Long version,Object request,String status) {
var now=codec.utc(Instant.now());var row=new Command();row.setId(IdWorker.getId());row.setDispatchId(id);
row.setOperatorId(actor.operatorId());row.setIdempotencyKey(key);row.setAction(action);row.setExpectedVersion(version);
row.setRequestHash(codec.hash(java.util.Arrays.asList(actor.operatorId(),action,version,request)));row.setRequestJson(codec.write(request));row.setStatus(status);row.setCreatedAt(now);row.setUpdatedAt(now);return row;
}
/** 首次上传先持久化意图,竞争者只能读取原状态,不再次调用File。 */
@Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED)
public Reservation reserveUpload(long id,Actor actor,String key,Object request) {
var previous=existing(id,key,actor);
if(previous!=null){same(previous,"UPLOAD_PHOTO",null,request);return new Reservation(previous,false);}
var row=create(id,actor,key,"UPLOAD_PHOTO",null,request,"PENDING");
try{repository.command(row);return new Reservation(row,true);}
catch(DuplicateKeyException race){var original=existing(id,key,actor);if(original==null)throw race;same(original,"UPLOAD_PHOTO",null,request);return new Reservation(original,false);}
}
/** 已知上传回执或未知结果只完成原意图,不创建新业务记录。 */
@Transactional(propagation=Propagation.REQUIRES_NEW)
public void finishUpload(Command command,String status,Object result,String errorCode) {
command.setStatus(status);command.setResultJson(result==null?null:codec.write(result));command.setErrorCode(errorCode);
command.setUpdatedAt(codec.utc(Instant.now()));
if(repository.finishUpload(command)!=1)throw conflict("上传命令结果已被其他操作确认,请核对原请求");
}
/** 写命令与附加更新、历史在同一外部事务提交。 */
public void success(long id,Actor actor,String key,String action,long version,Object request,Object result) {
var command=create(id,actor,key,action,version,request,"SUCCEEDED");command.setResultJson(codec.write(result));repository.command(command);
}
}
@@ -0,0 +1,52 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Ownership;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.time.*;
import java.util.Objects;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
/** 装车时车辆归属的唯一捕获实现,GET绝不补造历史。 */
@Service
public class OwnedBusinessOwnershipService implements OwnedBusinessOwnershipCapture {
private final OwnedBusinessRepository repository;
private final VehicleRepository vehicles;
/** 使用真实车辆提供者,不对车辆主档增加反向写锁。 */
public OwnedBusinessOwnershipService(OwnedBusinessRepository repository,VehicleRepository vehicles){this.repository=repository;this.vehicles=vehicles;}
/** 主流程原父事务已经稳定资源,捕获失败必须与装车一起回滚。 */
@Override @Transactional(propagation=Propagation.MANDATORY)
public void capture(long dispatchId,Long vehicleId,String resourceMode,String loadingCompletionHash,Instant loadingCompletedAt) {
if(dispatchId<=0 || loadingCompletionHash==null || loadingCompletionHash.isBlank() || loadingCompletionHash.length()>128 || loadingCompletedAt==null
|| !loadingCompletedAt.equals(loadingCompletedAt.truncatedTo(java.time.temporal.ChronoUnit.MILLIS)))throw bad("装车归属证据不完整");
if(!"MASTER".equals(resourceMode) && !"EXTERNAL".equals(resourceMode))throw bad("车辆资源模式不合法");
if("MASTER".equals(resourceMode)?vehicleId==null || vehicleId<=0:vehicleId!=null)throw bad("车辆资源身份不一致");
var existing=repository.ownership(dispatchId);
if(existing!=null){same(existing,vehicleId,resourceMode,loadingCompletionHash,loadingCompletedAt);return;}
var row=new Ownership();row.setDispatchId(dispatchId);row.setVehicleId(vehicleId);row.setResourceMode(resourceMode);
if("MASTER".equals(resourceMode)) {
var vehicle=vehicles.findById(vehicleId).orElseThrow(()->conflict("原车辆主档不存在"));
if(vehicle.id()!=vehicleId.longValue())throw conflict("车辆归属提供者返回的身份不一致");
if(vehicle.recordVersion()<0)throw conflict("原车辆档案版本无效");
if(!"OWNED".equals(vehicle.ownership()) && !"DISPATCHED".equals(vehicle.ownership()))throw conflict("车辆归属未明确");
row.setVehicleOwnership(vehicle.ownership());row.setVehicleRecordVersion(vehicle.recordVersion());
} else {row.setVehicleOwnership("DISPATCHED");row.setVehicleRecordVersion(null);}
row.setLoadingCompletedAt(LocalDateTime.ofInstant(loadingCompletedAt,ZoneOffset.UTC));
row.setOwnershipCapturedAt(row.getLoadingCompletedAt());row.setLoadingCompletionHash(loadingCompletionHash);
try{repository.capture(row);}catch(DuplicateKeyException duplicate){
var raced=repository.ownership(dispatchId);if(raced==null)throw duplicate;
same(raced,vehicleId,resourceMode,loadingCompletionHash,loadingCompletedAt);
if(!Objects.equals(raced.getVehicleOwnership(),row.getVehicleOwnership()) || !Objects.equals(raced.getVehicleRecordVersion(),row.getVehicleRecordVersion()))throw conflict("装车车辆归属并发变化");
}
}
/** 只允许同一原装车事实重放,不能以新归属重写旧证据。 */
private void same(Ownership row,Long vehicleId,String mode,String hash,Instant at) {
if(!Objects.equals(row.getVehicleId(),vehicleId) || !Objects.equals(row.getResourceMode(),mode)
|| !Objects.equals(row.getLoadingCompletionHash(),hash) || !Objects.equals(row.getLoadingCompletedAt(),LocalDateTime.ofInstant(at,ZoneOffset.UTC)))
throw conflict("原装车车辆归属证据不同");
}
}
@@ -0,0 +1,95 @@
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.repository.OwnedBusinessRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
/** 只有绑定成功才保存照片,上传和绑定各自使用稳定键。 */
@Service
public class OwnedBusinessPhotoService {
private final OwnedBusinessQueryService queries;
private final OwnedBusinessRecordService records;
private final OwnedBusinessRepository repository;
private final OwnedBusinessCommandService commands;
private final OwnedBusinessProjection projection;
private final OwnedBusinessSupport codec;
private final OwnedBusinessFileGateway files;
/** HTTP远程请求不在附加行写事务内持锁等待。 */
public OwnedBusinessPhotoService(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,
OwnedBusinessRepository repository,OwnedBusinessCommandService commands,OwnedBusinessProjection projection,
OwnedBusinessSupport codec,OwnedBusinessFileGateway files) {
this.queries=queries;this.records=records;this.repository=repository;this.commands=commands;this.projection=projection;this.codec=codec;this.files=files;
}
public record UploadIdentity(PhotoSlot slot,String fileName,String contentType,long sizeBytes,String sha256) {}
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);
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("无法读取待上传照片");}
var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes);
HttpOwnedBusinessFileGateway.validate(payload);
var identity=new UploadIdentity(slot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes));
var reservation=commands.reserveUpload(source.getId(),actor,key,identity);
if(!reservation.created()) {
if(!"SUCCEEDED".equals(reservation.command().getStatus()))throw conflict("原上传结果尚未明确,请查询原请求,勿换键重传");
return codec.read(reservation.command().getResultJson(),Photo.class);
}
try {
var result=photo(slot,files.upload(source.getId(),slot,actor.operatorId(),payload));
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");}
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("照片绑定请求缺失");
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);
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)));
}
/** 只有当前或指定真实历史版本中的引用允许代理读取。 */
@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;
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();
}
var photo=bound.stream().filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst().orElseThrow(OwnedBusinessSupport::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);
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);
}
/** 上传回执不填写保存时间,避免假称已经关联成功。 */
private Photo photo(PhotoSlot slot,OwnedBusinessFileGateway.Evidence evidence) {
return new Photo(slot,slot.category(),String.valueOf(evidence.fileId()),evidence.fileName(),evidence.contentType(),
String.valueOf(evidence.sizeBytes()),evidence.uploadedAt(),null);
}
}
@@ -0,0 +1,77 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
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.OwnedBusinessRows.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import org.springframework.stereotype.Component;
import java.math.*;
import java.util.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
/** 组合原排车与附加信息;不复制、推进或重新归并订单。 */
@Component
public class OwnedBusinessProjection {
private final OwnedBusinessSupport codec;
/** 所有数值与日期转换沿统一显式规则。 */
public OwnedBusinessProjection(OwnedBusinessSupport codec){this.codec=codec;}
/** 未登记为null,不以默认油耗、金额或到达填充。 */
public Operations operations(Record record) {
if(record==null)return new Operations(null,null,null,List.of());
var money=record.getTotalAmount()==null?null:new Money(record.getCurrency(),record.getTotalAmount().setScale(2).toPlainString());
return new Operations(money,decimal(record.getActualDistanceMiles()),decimal(record.getFuelConsumptionLitresPer100Km()),
codec.list(record.getArrivalsJson(),Arrival.class));
}
/** 照片已绑定才出现在当前记录,无文件不生成占位图。 */
public List<Photo> photos(Record record){return record==null?List.of():codec.list(record.getPhotosJson(),Photo.class);}
/** 使用原货物确定目的地key,保持与排车及到达登记一致。 */
public String destinationKey(CargoRow item) {
String key=FleetDispatchGateway.destinationKey(item.getDestinationType(),item.getDestinationWarehouseCode(),
item.getFbaWarehouseCode(),item.getDeliveryAddress(),item.getPostalCode());
if(key==null || key.isBlank())throw OwnedBusinessSupport.conflict("原货物目的地事实缺失");return key;
}
/** 真实物理托盘唯一计数,标准托位和来源条数不参与除数。 */
public int palletCount(Source source) {
var confirmed=codec.read(source.getLoadingConfirmationJson(),ConfirmedLoad.class);
if(confirmed.pallets()==null)throw OwnedBusinessSupport.conflict("实际装车托盘事实缺失");
var ids=new HashSet<Long>();
for(var pallet:confirmed.pallets()) {
if(pallet==null || pallet.warehousePalletId()==null || pallet.warehousePalletId()<=0)throw OwnedBusinessSupport.conflict("实际装车托盘身份缺失");
ids.add(pallet.warehousePalletId());
}
return ids.size();
}
/** 一车一行;目的地与预约只行内展开,不重复整车金额或托数。 */
public Row row(Source source,Record record,List<CargoRow> cargo,List<Batch> batches,Actor actor) {
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);
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) {
String key=destinationKey(item);
destinations.computeIfAbsent(key,k->new Destination(k,item.getDestinationWarehouseCode()==null || item.getDestinationWarehouseCode().isBlank()?item.getFbaWarehouseCode():item.getDestinationWarehouseCode(),item.getDestinationWarehouseName(),
item.getDeliveryAddress(),batches.stream().filter(b->k.equals(b.getDestinationKey())).map(b->new Appointment(
b.getAppointmentNo(),b.getConfirmationNo(),codec.iso(b.getStartUtc()),String.valueOf(b.getAllocatedPackages()))).toList(),arrivals.get(k)));
}
String price=ops.money()==null || pallets==0?null:new BigDecimal(ops.money().amount()).divide(BigDecimal.valueOf(pallets),2,RoundingMode.HALF_UP).toPlainString();
var driver=resources.driver();
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 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());
}
/** 附加权限独立于原排车写动作。 */
private Gate gate(Actor actor,String permission) {
boolean allowed=actor!=null && actor.permissions()!=null && actor.permissions().contains(permission);
return new Gate(allowed,allowed?null:"没有该业务操作权限");
}
/** 数据库十进制保持显式单位,不以空值替换0。 */
private String decimal(BigDecimal value){return value==null?null:value.toPlainString();}
}
@@ -0,0 +1,98 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
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;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
/** 自有车辆装车后视图;无归属快照的旧单不在GET时回填。 */
@Service
public class OwnedBusinessQueryService {
private final OwnedBusinessRepository repository;
private final OwnedBusinessProjection projection;
private final OwnedBusinessSupport codec;
private final DispatchDetailReader dispatch;
/** 主单详情沿既有真实提供者,列表使用本域批量投影。 */
public OwnedBusinessQueryService(OwnedBusinessRepository repository,OwnedBusinessProjection projection,
OwnedBusinessSupport codec,DispatchDetailReader dispatch){this.repository=repository;this.projection=projection;this.codec=codec;this.dispatch=dispatch;}
/** 四项统计与分页使用同一过滤和快照,GET不写库。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public ListResult list(Actor actor,Query query) {
codec.require(actor,LIST);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 counts=repository.summary(search);int after=0,delivering=0,delivered=0;
for(var c:counts) {
int n=Math.toIntExact(c.getQuantity());
switch(c.getStage()){case "AFTER_LOADING"->after=n;case "DELIVERING"->delivering=n;case "DELIVERED"->delivered=n;default->throw conflict("阶段统计事实不合法");}
}
int total=Math.addExact(Math.addExact(after,delivering),delivered);
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()),
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));
}
/** 对原排车详情做组合,不创建第二份主单或履约状态。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Detail detail(Actor actor,String number){codec.require(actor,LIST);return detailForWrite(actor,requireOwned(number));}
/** 写入已经有独立权限;回执仍由相同真实来源组成。 */
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);
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(),
projection.photos(repository.record(source.getId())));
}
/** 缺少历史归属必须明确拒绝,不能按当前车辆主档降级猜测。 */
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;
}
/** 阶段、真实装车hash及车辆快照须与捕获证据一致。 */
private void eligible(Source source) {
if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入自有业务");
if(!"OWNED".equals(source.getOwnedVehicleOwnership()))throw 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())
|| !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);
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;
return new History(h.getEventSource(),h.getRecordVersion()==null?null:h.getRecordVersion().toString(),
h.getDispatchRecordVersion()==null?null:h.getDispatchRecordVersion().toString(),h.getAction(),snapshot==null?null:snapshot.operatorName(),codec.iso(h.getOccurredAt()),h.getReason(),
snapshot==null?null:snapshot.operations(),snapshot==null?List.of():snapshot.photos());
}).toList(),page,size,repository.historyCount(source.getId()));
}
/** 页码初始1,缺失与非法值不能混同。 */
private int page(Integer value){if(value==null)return 1;if(value<1)throw bad("页码须大于0");return value;}
/** 列表和历史均限制单页读取量。 */
private int size(Integer value){if(value==null)return 20;if(value<1 || value>200)throw bad("每页条数须为1至200");return value;}
/** 空白可选条件归一化,无限长输入明确拒绝。 */
private String optional(String value,int max){if(value==null || value.isBlank())return null;if(value.length()>max)throw bad("筛选条件过长");return value.trim();}
}
@@ -0,0 +1,122 @@
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.OwnedBusinessRows;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
/** 运营信息和分类照片只写本页附加版本,绝不改主单版本或发布费用。 */
@Service
public class OwnedBusinessRecordService {
private final OwnedBusinessRepository repository;
private final OwnedBusinessQueryService queries;
private final OwnedBusinessProjection projection;
private final OwnedBusinessCommandService commands;
private final OwnedBusinessSupport codec;
/** 业务规则经Repository读写,命令与历史一起提交。 */
public OwnedBusinessRecordService(OwnedBusinessRepository repository,OwnedBusinessQueryService queries,
OwnedBusinessProjection projection,OwnedBusinessCommandService commands,OwnedBusinessSupport codec) {
this.repository=repository;this.queries=queries;this.projection=projection;this.commands=commands;this.codec=codec;
}
/** 整车只保留一份可更正/清空的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("运营登记内容缺失");
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());
var source=queries.requireOwned(number);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);
previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(previous!=null){commands.same(previous,"SAVE_OPERATIONS",version,request);return result(previous);}
requireVersion(row,version);
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());
}
/** 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);
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);}
if(slot!=verified.slot() || !Objects.equals(slot.category(),verified.category()) || !Objects.equals(request.fileId(),verified.fileId()))throw bad("照片不属于本框");
var row=lock(source,actor);old=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);}
requireVersion(row,version);
var photos=new EnumMap<PhotoSlot,Photo>(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p);
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));
}
/** 操作记录使用准确中文槽位,不向用户展示内部枚举。 */
private String photoReason(PhotoSlot slot) {
return switch(slot) {
case LOADING_SCENE -> "保存装车现场照片";
case LOADING_LIST -> "保存装车清单照片";
case PRE_DEPARTURE_CHECK -> "保存出车前检查照片";
case DRIVER_LICENSE -> "保存驾驶证附件照片";
case RECEIPT -> "保存签收凭证照片";
case FUEL_START -> "保存起始油耗表盘照片";
case FUEL_END -> "保存结束油耗表盘照片";
};
}
/** 照片框也参与摘要,不能换URL框位复用原键。 */
public record PhotoBinding(PhotoSlot slot,BindPhoto request) {}
/** 先校验所有目的地及数值,再进入附加行写事务。 */
private Operations normalize(Source source,Operations ops) {
Money money=null;
if(ops.money()!=null) {
if(!Set.of("CNY","USD").contains(ops.money().currency()==null?"":ops.money().currency()))throw bad("币种须为CNY或USD");
String amount=codec.decimal(ops.money().amount(),16,2);if(amount==null)throw bad("金额与币种必须同时登记");
money=new Money(ops.money().currency(),amount);
}
if(ops.arrivals()==null)throw bad("目的地到达登记不完整");
var destinations=new HashSet<String>();for(var item:repository.cargo(List.of(source.getId())))destinations.add(projection.destinationKey(item));
var seen=new HashSet<String>();var arrivals=new ArrayList<Arrival>();
for(var arrival:ops.arrivals()) {
if(arrival==null || arrival.destinationKey()==null || !destinations.contains(arrival.destinationKey()) || !seen.add(arrival.destinationKey()))throw bad("到达目的地重复或不属于本车");
var at=codec.instant(arrival.actualArrivedAt());arrivals.add(new Arrival(arrival.destinationKey(),at==null?null:at.toString()));
}
arrivals.sort(Comparator.comparing(Arrival::destinationKey));
return new Operations(money,codec.decimal(ops.actualDistanceMiles(),9,3),
codec.decimal(ops.fuelConsumptionLitresPer100Km(),9,3),List.copyOf(arrivals));
}
/** 首次写建立0版本附加行,仅在成功事务中最终提交。 */
private Record lock(Source source,Actor actor) {
var seed=new Record();seed.setId(IdWorker.getId());seed.setDispatchId(source.getId());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)throw conflict("附加记录初始化失败");return row;
}
/** 版本变化必须由浏览器刷新,不能覆盖其他操作者的登记。 */
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) {
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;
}
/** 重放返回原成功结果,不能从现在的主单/附加版本重算。 */
private Detail result(OwnedBusinessRows.Command command) {
if(!"SUCCEEDED".equals(command.getStatus()))throw conflict("原命令结果尚未明确,请按原请求恢复");
return codec.read(command.getResultJson(),Detail.class);
}
/** null是未登记,不以0代替。 */
private BigDecimal decimal(String value){return value==null?null:new BigDecimal(value);}
}
@@ -0,0 +1,89 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
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.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JavaType;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.*;
import java.util.*;
/** 只处理本页格式、身份和JSON,不读写主流程。 */
@Component
public class OwnedBusinessSupport {
private final ObjectMapper json;
/** 复用服务统一Jackson,不建立第二套日期或版本协议。 */
public OwnedBusinessSupport(ObjectMapper json){this.json=json;}
/** 平台身份和操作者均取可信会话,不接收浏览器归属参数。 */
public Actor actor(AuthenticatedUser user) {
if(user==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可管理自有业务");
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();
return new Actor(operator,name,user.permissions()==null?Set.of():Set.copyOf(user.permissions()));
}
/** 写权限独立校验,直接Service调用也不能绕过。 */
public void require(Actor actor,String permission) {
if(actor==null || actor.operatorId()==null || actor.permissions()==null || !actor.permissions().contains(permission))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"没有该业务操作权限");
}
/** JSON只从本页记录和已确认排车事实读取,损坏不静默恢复空值。 */
public <T>T read(String value,Class<T> type) {
if(value==null || value.isBlank())throw conflict("缺少可靠业务事实");
try{return json.readValue(value,type);}catch(JsonProcessingException error){throw conflict("业务事实读取失败,请核对原记录");}
}
/** 集合记录与声明的元素类型共同校验。 */
public <T>List<T> list(String value,Class<T> type) {
JavaType listType=json.getTypeFactory().constructCollectionType(List.class,type);
try{return json.readValue(value,listType);}catch(Exception error){throw conflict("业务集合事实读取失败");}
}
/** 原请求和原结果完整持久化,序列化失败必须中止命令。 */
public String write(Object value) {
try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("业务记录序列化失败",error);}
}
/** 请求摘要不包含版本漂移后的当前数据。 */
public String hash(Object value){return digest(write(value).getBytes(StandardCharsets.UTF_8));}
/** 文件内容摘要用于识别相同上传原请求,不保存图片进数据库。 */
public String digest(byte[] bytes) {
try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));}
catch(Exception error){throw new IllegalStateException("无法计算业务摘要",error);}
}
/** 版本0合法,但缺失、负数和超长版本不得降级为0。 */
public long version(String value) {
try{if(value==null || !value.matches("0|[1-9][0-9]*"))throw new IllegalArgumentException();return Long.parseLong(value);}
catch(Exception error){throw bad("请提交有效的原记录版本");}
}
/** 请求键在页面恢复期间保持稳定,不能用空键创建命令。 */
public void key(String key){if(key==null || key.length()<8 || key.length()>128)throw bad("请求键长度须为8至128");}
/** 只接受带时区的真实时间,统一存UTC。 */
public Instant instant(String value) {
try{return value==null?null:OffsetDateTime.parse(value).toInstant();}
catch(Exception error){throw bad("时间须包含明确时区");}
}
/** 关系库时间只表示UTC,不取运行机默认时区。 */
public LocalDateTime utc(Instant value){return value==null?null:LocalDateTime.ofInstant(value,ZoneOffset.UTC);}
/** 展示原真实时间,不根据签收或预计时间填充。 */
public String iso(LocalDateTime value){return value==null?null:value.toInstant(ZoneOffset.UTC).toString();}
/** 规范十进制输入,空值与明确零分别保存。 */
public String decimal(String value,int integers,int scale) {
if(value==null)return null;
if(!value.matches("(0|[1-9][0-9]{0,"+(integers-1)+"})(\\.[0-9]{1,"+scale+"})?"))throw bad("数量或金额须为非负规范十进制");
return new BigDecimal(value).setScale(scale).toPlainString();
}
/** 用户输入错误保持项目业务错误约定。 */
public static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);}
/** 业务并发或来源事实不符不能返回模拟成功。 */
public static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);}
/** 不存在只使用业务提示,不泄露内部主键。 */
public static BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前自有业务");}
}
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.fleet.trip;
/** 日历专用只读查询投影,真实排车号不属于车次持久化字段。 */
public class FleetTripCalendarRow extends FleetTripEntity {
private String dispatchNo;
/** 返回关联整车主单的业务号。 */
public String getDispatchNo() { return dispatchNo; }
/** 仅供日历SQL结果映射,不写入车次表。 */
public void setDispatchNo(String dispatchNo) { this.dispatchNo = dispatchNo; }
}
@@ -11,10 +11,10 @@ import org.springframework.web.server.ResponseStatusException;
import java.time.*; import java.time.*;
import java.util.*; import java.util.*;
/** Read-only transport plans. A trip is never evidence of a receiving appointment confirmation. */ /** 排车日历沿真实整车主单读取运输时间,不代表已取得目的仓收货确认。 */
@Service @Service
public class FleetTripCalendarService { public class FleetTripCalendarService {
public record Entry(String sourceType, String tripNo, String status, String originWarehouseId, public record Entry(String sourceType, String dispatchNo, String status, String originWarehouseId,
String originWarehouseCode, String originWarehouseName, String originCountryCode, String originWarehouseCode, String originWarehouseName, String originCountryCode,
String originTimezone, String originWarehouseStatus, Instant plannedDepartureAt, String originTimezone, String originWarehouseStatus, Instant plannedDepartureAt,
Instant expectedDriverEndAt, Instant departedAt, Instant completedAt, Instant expectedDriverEndAt, Instant departedAt, Instant completedAt,
@@ -31,12 +31,13 @@ public class FleetTripCalendarService {
this.trips=trips; this.warehouses=warehouses; this.json=json; this.token=token; this.trips=trips; this.warehouses=warehouses; this.json=json; this.token=token;
} }
/** 一次读取真实排车及一次仓库资料查询,按业务地点判定日历归属。 */
@Transactional(readOnly=true) @Transactional(readOnly=true)
public Result calendar(Instant from, Instant to) { public Result calendar(Instant from, Instant to) {
if (from==null || to==null || !to.isAfter(from) || Duration.between(from,to).compareTo(Duration.ofDays(44))>0) if (from==null || to==null || !to.isAfter(from) || Duration.between(from,to).compareTo(Duration.ofDays(44))>0)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"查询须为不超过44天的UTC时间范围"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"查询须为不超过44天的UTC时间范围");
var rows=trips.calendar(LocalDateTime.ofInstant(from,ZoneOffset.UTC),LocalDateTime.ofInstant(to,ZoneOffset.UTC)); var rows=trips.calendar(LocalDateTime.ofInstant(from,ZoneOffset.UTC),LocalDateTime.ofInstant(to,ZoneOffset.UTC));
if (rows.size()>2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"车次过多,请缩小查询日期范围"); if (rows.size()>2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"排车过多,请缩小查询日期范围");
if (rows.isEmpty()) return new Result(List.of(),List.of()); if (rows.isEmpty()) return new Result(List.of(),List.of());
var ids=rows.stream().map(FleetTripEntity::getWarehouseId).filter(Objects::nonNull).filter(id->id>0).distinct().sorted().toList(); var ids=rows.stream().map(FleetTripEntity::getWarehouseId).filter(Objects::nonNull).filter(id->id>0).distinct().sorted().toList();
var metadata=metadata(ids); var metadata=metadata(ids);
@@ -60,7 +61,7 @@ public class FleetTripCalendarService {
throw new IllegalArgumentException("incomplete route"); throw new IllegalArgumentException("incomplete route");
stops=List.copyOf(stops); stops=List.copyOf(stops);
} catch (Exception invalid) { stops=List.of(); reasons.add("ROUTE_INCOMPLETE"); } } catch (Exception invalid) { stops=List.of(); reasons.add("ROUTE_INCOMPLETE"); }
var entry=new Entry("TRIP_PLAN",row.getTripNo(),row.getStatus(),row.getWarehouseId()==null?null:row.getWarehouseId().toString(), var entry=new Entry("DISPATCH",row.getDispatchNo(),row.getStatus(),row.getWarehouseId()==null?null:row.getWarehouseId().toString(),
warehouse==null?row.getWarehouseCode():warehouse.warehouseCode(),warehouse==null?row.getWarehouseName():warehouse.warehouseName(), warehouse==null?row.getWarehouseCode():warehouse.warehouseCode(),warehouse==null?row.getWarehouseName():warehouse.warehouseName(),
country,zone,warehouse==null?null:warehouse.status(),instant(row.getPlannedDepartureAt()),instant(row.getExpectedDriverEndAt()), country,zone,warehouse==null?null:warehouse.status(),instant(row.getPlannedDepartureAt()),instant(row.getExpectedDriverEndAt()),
instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,List.copyOf(reasons)); instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,List.copyOf(reasons));
@@ -7,17 +7,18 @@ import org.apache.ibatis.annotations.Select;
@Mapper @Mapper
public interface FleetTripMapper extends BaseMapper<FleetTripEntity> { public interface FleetTripMapper extends BaseMapper<FleetTripEntity> {
/** 预约日历只读海外仓出库派送,取货入仓车次不属于排车类型。 */ /** 日历只读真实整车主单的出库派送,一单一条;独立车次及取消主单不进入排车日历。 */
@Select(""" @Select("""
SELECT trip_no,status,warehouse_id,warehouse_code,warehouse_name,planned_departure_at, SELECT d.dispatch_no,t.trip_no,t.status,t.warehouse_id,t.warehouse_code,t.warehouse_name,t.planned_departure_at,
expected_driver_end_at,departed_at,completed_at,stops_json t.expected_driver_end_at,t.departed_at,t.completed_at,t.stops_json
FROM ll_fleet_trip FROM ll_dispatch_order d JOIN ll_fleet_trip t ON t.id=d.trip_id
WHERE trip_purpose='DELIVERY' AND ( WHERE d.status='ACTIVE' AND d.dispatch_no IS NOT NULL AND TRIM(d.dispatch_no)<>''
(departed_at IS NOT NULL AND departed_at>=#{from} AND departed_at<#{to}) AND t.trip_purpose='DELIVERY' AND (
OR (departed_at IS NULL AND planned_departure_at>=#{from} AND planned_departure_at<#{to})) (t.departed_at IS NOT NULL AND t.departed_at>=#{from} AND t.departed_at<#{to})
ORDER BY COALESCE(departed_at,planned_departure_at),trip_no LIMIT 2001 OR (t.departed_at IS NULL AND t.planned_departure_at>=#{from} AND t.planned_departure_at<#{to}))
ORDER BY COALESCE(t.departed_at,t.planned_departure_at),d.dispatch_no LIMIT 2001
""") """)
java.util.List<FleetTripEntity> calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to); java.util.List<FleetTripCalendarRow> calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to);
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE") @Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE")
FleetTripEntity lockByNo(@Param("tripNo") String tripNo); FleetTripEntity lockByNo(@Param("tripNo") String tripNo);
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}") @Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}")
@@ -18,13 +18,14 @@ import org.springframework.web.server.ResponseStatusException;
@RequestMapping("/api/platform/v1/last-leg/fleet/trips") @RequestMapping("/api/platform/v1/last-leg/fleet/trips")
public class PlatformFleetTripController { public class PlatformFleetTripController {
@jakarta.annotation.Resource private FleetTripCalendarService calendar; @jakarta.annotation.Resource private FleetTripCalendarService calendar;
/** 预约排车日历沿用真实排车列表读取权限,不要求已退出菜单的车次管理权限。 */
@GetMapping("/calendar") @GetMapping("/calendar")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<FleetTripCalendarService.Result> calendar(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<FleetTripCalendarService.Result> calendar(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam java.time.Instant from,@RequestParam java.time.Instant to) { @RequestParam java.time.Instant from,@RequestParam java.time.Instant to) {
operator(user); operator(user);
if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台主体可读取车次日历"); throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台主体可读取排车日历");
return ApiResponse.success(calendar.calendar(from,to)); return ApiResponse.success(calendar.calendar(from,to));
} }
@@ -68,7 +68,9 @@ public class RelabelDispatchSourceRepository {
if (position == null) position = mapper.position(item.getSourceInventoryPositionId()); if (position == null) position = mapper.position(item.getSourceInventoryPositionId());
if (position == null) continue; if (position == null) continue;
positions.put(position.getId(), position); positions.put(position.getId(), position);
// 无效身份只作为本申请缺失组成保留,不能把其他未绑定位置误连为同一实托。
if (warehouseId.equals(position.getOverseasWarehouseId()) && position.getPalletId() != null if (warehouseId.equals(position.getOverseasWarehouseId()) && position.getPalletId() != null
&& position.getPalletId() > 0
&& !visitedPallets.contains(position.getPalletId())) pending.add(position.getPalletId()); && !visitedPallets.contains(position.getPalletId())) pending.add(position.getPalletId());
} }
} }
@@ -149,8 +149,9 @@ public class RelabelDispatchSourceService implements RelabelDispatchSourcePort {
long notified = number(item.getNotifiedPackageCount()); long notified = number(item.getNotifiedPackageCount());
if (!Objects.equals(item.getCustomerId(), app.getCustomerId()) || position == null if (!Objects.equals(item.getCustomerId(), app.getCustomerId()) || position == null
|| !Objects.equals(position.getCustomerId(), app.getCustomerId()) || !Objects.equals(position.getCustomerId(), app.getCustomerId())
|| !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null) { || !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null
reasons.add(app.getRelabelNo() + ":明细当前位置缺失或客户、仓库归属不一致"); || position.getPalletId() <= 0) {
reasons.add(app.getRelabelNo() + ":明细当前位置缺失、实托身份无效或客户、仓库归属不一致");
bound = false; bound = false;
} }
if (item.getReturnCartonId() != null || "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) { if (item.getReturnCartonId() != null || "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) {
@@ -4553,3 +4553,82 @@ CREATE TABLE IF NOT EXISTS ll_fleet_pickup_history (
KEY idx_pickup_history_source(customer_id,transfer_no,id), KEY idx_pickup_history_source(customer_id,transfer_no,id),
KEY idx_pickup_history_trip(trip_no,id) KEY idx_pickup_history_trip(trip_no,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership (
dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键',
resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL',
vehicle_id BIGINT NULL COMMENT '真实车辆档案主键,临时外配为空',
vehicle_ownership VARCHAR(16) NOT NULL COMMENT '装车完成时车辆归属OWNED或DISPATCHED',
vehicle_record_version BIGINT NULL COMMENT '捕获归属时真实车辆档案版本',
ownership_captured_at DATETIME(3) NOT NULL COMMENT '归属捕获UTC时间',
loading_completion_hash VARCHAR(128) NOT NULL COMMENT '原排车装车确认事实摘要',
loading_completed_at DATETIME(3) NOT NULL COMMENT '原排车实际装车完成UTC时间',
PRIMARY KEY (dispatch_id),
KEY idx_owned_business_vehicle (vehicle_ownership,vehicle_id),
CONSTRAINT fk_owned_business_ownership_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_ownership CHECK (vehicle_ownership IN ('OWNED','DISPATCHED')),
CONSTRAINT ck_owned_business_resource_mode CHECK (
(resource_mode='MASTER' AND vehicle_id IS NOT NULL AND vehicle_record_version IS NOT NULL AND vehicle_record_version>=0)
OR (resource_mode='EXTERNAL' AND vehicle_id IS NULL AND vehicle_record_version IS NULL AND vehicle_ownership='DISPATCHED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车装车完成时的车辆归属证据';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record (
id BIGINT NOT NULL COMMENT '附加运营记录内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
record_version BIGINT NOT NULL COMMENT '附加运营记录并发版本,独立于原排车版本',
currency VARCHAR(3) NULL COMMENT '整车运营金额币种CNY或USD,未登记为空',
total_amount DECIMAL(18,2) NULL COMMENT '人工登记整车总金额,不自动形成账务费用',
actual_distance_miles DECIMAL(12,3) NULL COMMENT '实际行驶英里数,未登记为空',
fuel_consumption_litres_per_100km DECIMAL(12,3) NULL COMMENT '人工登记油耗率,单位升每百公里',
arrivals_json JSON NOT NULL COMMENT '按原目的地键记录的实际到达UTC时间',
photos_json JSON NOT NULL COMMENT '七个照片框当前已绑定文件的分类快照',
created_by VARCHAR(64) NOT NULL COMMENT '首次登记操作人标识',
updated_by VARCHAR(64) NOT NULL COMMENT '最近登记操作人标识',
created_at DATETIME(3) NOT NULL COMMENT '首次登记UTC时间',
updated_at DATETIME(3) NOT NULL COMMENT '最近登记UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_owned_business_record_dispatch (dispatch_id),
CONSTRAINT fk_owned_business_record_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_record_version CHECK (record_version>=0),
CONSTRAINT ck_owned_business_money CHECK (
(currency IS NULL AND total_amount IS NULL)
OR (currency IS NOT NULL AND currency IN ('CNY','USD') AND total_amount IS NOT NULL AND total_amount>=0)),
CONSTRAINT ck_owned_business_distance CHECK (actual_distance_miles IS NULL OR actual_distance_miles>=0),
CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command (
id BIGINT NOT NULL COMMENT '附加运营命令内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作人标识',
idempotency_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '同一原排车内唯一请求键,不按操作者分组',
action VARCHAR(32) NOT NULL COMMENT '运营登记或照片上传绑定动作',
expected_version BIGINT NULL COMMENT '原请求附加版本,纯文件上传可为空',
request_hash CHAR(64) NOT NULL COMMENT '原操作者动作版本与完整请求摘要',
request_json JSON NOT NULL COMMENT '不含文件内容或凭据的完整原请求',
status VARCHAR(24) NOT NULL COMMENT 'PENDING或SUCCEEDED或REJECTED或UNKNOWN_RESULT',
result_json JSON NULL COMMENT '原命令已知成功结果或明确拒绝事实',
error_code VARCHAR(64) NULL COMMENT '明确拒绝或未知结果诊断码',
created_at DATETIME(3) NOT NULL COMMENT '命令意图创建UTC时间',
updated_at DATETIME(3) NOT NULL COMMENT '命令结果更新UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_owned_business_command_key (dispatch_id,idempotency_key),
CONSTRAINT fk_owned_business_command_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_command_version CHECK (expected_version IS NULL OR expected_version>=0),
CONSTRAINT ck_owned_business_command_status CHECK (status IN ('PENDING','SUCCEEDED','REJECTED','UNKNOWN_RESULT'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车附加运营写命令及可恢复结果';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
id BIGINT NOT NULL COMMENT '附加运营历史内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
record_version BIGINT NOT NULL COMMENT '成功写入后的附加记录版本',
action VARCHAR(32) NOT NULL COMMENT '运营登记或照片绑定动作',
operator_id VARCHAR(64) NOT NULL COMMENT '真实操作人标识',
reason VARCHAR(1000) NULL COMMENT '人工填写的登记或更正说明',
snapshot_json JSON NOT NULL COMMENT '操作后附加运营信息与分类照片不可变快照',
occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_owned_business_history_version (dispatch_id,record_version),
CONSTRAINT fk_owned_business_history_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_history_version CHECK (record_version>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史';
@@ -0,0 +1,972 @@
openapi: "3.0.3"
info:
title: "原排车自有车辆运营视图"
version: "1.0.0"
description: "装车完成时冻结车辆OWNED/DISPATCHED;所有主单事实来自原dispatch。新增数据仅附加运营记录、命令、历史及归属证据。油耗L/100km手工登记,金额CNY/USD不默认、不换汇;零与未登记不同。"
security:
-
bearerAuth: []
paths:
"/api/platform/v1/last-leg/fleet/owned-business":
get:
operationId: "listOwnedBusiness"
summary: "自有车辆装车后视图及同筛选四项统计"
description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:list"
responses:
200:
description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。"
content:
"application/json":
schema:
type: "object"
properties:
code:
type: "string"
message:
type: "string"
data:
allOf:
-
"$ref": "#/components/schemas/ListResult"
nullable: true
parameters:
-
name: "page"
in: "query"
required: false
schema:
type: "integer"
minimum: 1
default: 1
-
name: "size"
in: "query"
required: false
schema:
type: "integer"
minimum: 1
maximum: 200
default: 20
-
name: "plannedDepartureFrom"
in: "query"
required: false
schema:
type: "string"
format: "date-time"
-
name: "plannedDepartureTo"
in: "query"
required: false
schema:
type: "string"
format: "date-time"
-
name: "keyword"
in: "query"
required: false
schema:
type: "string"
maxLength: 128
-
name: "stage"
in: "query"
required: false
schema:
"$ref": "#/components/schemas/Stage"
-
name: "warehouseId"
in: "query"
required: false
schema:
type: "string"
maxLength: 64
x-filter-contract: "plannedDepartureAt [from,to),统计同全部过滤、不带分页;无归属快照不降级按当前主档推断。"
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}":
parameters:
-
name: "dispatchNo"
in: "path"
required: true
schema:
type: "string"
maxLength: 128
get:
operationId: "getOwnedBusiness"
summary: "组合原排车详情及附加运营信息"
description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:list"
responses:
200:
description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。"
content:
"application/json":
schema:
type: "object"
properties:
code:
type: "string"
message:
type: "string"
data:
allOf:
-
"$ref": "#/components/schemas/Detail"
nullable: true
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/history":
parameters:
-
name: "dispatchNo"
in: "path"
required: true
schema:
type: "string"
maxLength: 128
get:
operationId: "historyOwnedBusiness"
summary: "原排车及附加真实流水聚合分页"
description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:list"
responses:
200:
description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。"
content:
"application/json":
schema:
type: "object"
properties:
code:
type: "string"
message:
type: "string"
data:
allOf:
-
"$ref": "#/components/schemas/HistoryPage"
nullable: true
parameters:
-
name: "page"
in: "query"
required: false
schema:
type: "integer"
minimum: 1
default: 1
-
name: "size"
in: "query"
required: false
schema:
type: "integer"
minimum: 1
maximum: 200
default: 20
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/operations":
parameters:
-
name: "dispatchNo"
in: "path"
required: true
schema:
type: "string"
maxLength: 128
put:
operationId: "saveOwnedBusinessOperations"
summary: "按附加版本登记、更正或清空运营信息"
description: "权限:platform:fleet:owned-business:edit。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:edit"
responses:
200:
description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。"
content:
"application/json":
schema:
type: "object"
properties:
code:
type: "string"
message:
type: "string"
data:
allOf:
-
"$ref": "#/components/schemas/Detail"
nullable: true
requestBody:
required: true
content:
"application/json":
schema:
"$ref": "#/components/schemas/SaveOperations"
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/photos/{slot}/uploads":
parameters:
-
name: "dispatchNo"
in: "path"
required: true
schema:
type: "string"
maxLength: 128
-
name: "slot"
in: "path"
required: true
schema:
"$ref": "#/components/schemas/PhotoSlot"
post:
operationId: "uploadOwnedBusinessPhoto"
summary: "逐框准备照片,尚未保存绑定"
description: "权限:platform:fleet:owned-business:upload。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:upload"
responses:
200:
description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。"
content:
"application/json":
schema:
type: "object"
properties:
code:
type: "string"
message:
type: "string"
data:
allOf:
-
"$ref": "#/components/schemas/Photo"
nullable: true
requestBody:
required: true
content:
"multipart/form-data":
schema:
type: "object"
required:
- "file"
- "idempotencyKey"
properties:
idempotencyKey:
type: "string"
minLength: 8
maxLength: 128
description: "原上传请求键,以multipart表单字段提交,绑定使用独立请求键。"
file:
type: "string"
format: "binary"
description: "真实JPG/PNG,非空,最大10MiB;与独立绑定命令使用不同请求键。"
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/photos/{slot}":
parameters:
-
name: "dispatchNo"
in: "path"
required: true
schema:
type: "string"
maxLength: 128
-
name: "slot"
in: "path"
required: true
schema:
"$ref": "#/components/schemas/PhotoSlot"
put:
operationId: "bindOwnedBusinessPhoto"
summary: "保存原上传fileId;失败保留原键恢复,不重新上传"
description: "权限:platform:fleet:owned-business:upload。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:upload"
responses:
200:
description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。"
content:
"application/json":
schema:
type: "object"
properties:
code:
type: "string"
message:
type: "string"
data:
allOf:
-
"$ref": "#/components/schemas/Detail"
nullable: true
requestBody:
required: true
content:
"application/json":
schema:
"$ref": "#/components/schemas/BindPhoto"
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/photos/{slot}/files/{fileId}/content":
parameters:
-
name: "dispatchNo"
in: "path"
required: true
schema:
type: "string"
maxLength: 128
-
name: "slot"
in: "path"
required: true
schema:
"$ref": "#/components/schemas/PhotoSlot"
-
name: "fileId"
in: "path"
required: true
schema:
type: "string"
pattern: "^[1-9][0-9]*$"
get:
operationId: "readOwnedBusinessPhoto"
summary: "授权读取当前或原历史绑定图片"
description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:list"
responses:
200:
description: "仅当前或指定历史引用的图片字节,Cache-Control:no-store,X-Content-Type-Options:nosniff"
content:
"image/jpeg":
schema:
type: "string"
format: "binary"
"image/png":
schema:
type: "string"
format: "binary"
parameters:
-
name: "revisionVersion"
in: "query"
required: false
schema:
type: "integer"
format: "int64"
minimum: 1
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/commands/{idempotencyKey}":
parameters:
-
name: "dispatchNo"
in: "path"
required: true
schema:
type: "string"
maxLength: 128
-
name: "idempotencyKey"
in: "path"
required: true
schema:
type: "string"
minLength: 8
maxLength: 128
get:
operationId: "recoverOwnedBusinessCommand"
summary: "恢复本操作者原命令,未知结果不得换键重发"
description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:list"
responses:
200:
description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。"
content:
"application/json":
schema:
type: "object"
properties:
code:
type: "string"
message:
type: "string"
data:
allOf:
-
"$ref": "#/components/schemas/Command"
nullable: true
components:
securitySchemes:
bearerAuth:
type: "http"
scheme: "bearer"
bearerFormat: "JWT"
schemas:
Stage:
type: "string"
enum:
- "AFTER_LOADING"
- "DELIVERING"
- "DELIVERED"
PhotoSlot:
type: "string"
enum:
- "LOADING_SCENE"
- "LOADING_LIST"
- "PRE_DEPARTURE_CHECK"
- "DRIVER_LICENSE"
- "RECEIPT"
- "FUEL_START"
- "FUEL_END"
Money:
type: "object"
x-java-record: "OwnedBusinessModels.Money"
required:
- "currency"
- "amount"
properties:
currency:
type: "string"
enum:
- "CNY"
- "USD"
amount:
type: "string"
pattern: "^(0|[1-9][0-9]{0,15})(\\.[0-9]{1,2})?$"
Arrival:
type: "object"
x-java-record: "OwnedBusinessModels.Arrival"
required:
- "destinationKey"
- "actualArrivedAt"
properties:
destinationKey:
type: "string"
minLength: 1
maxLength: 256
actualArrivedAt:
type: "string"
nullable: true
format: "date-time"
Operations:
type: "object"
x-java-record: "OwnedBusinessModels.Operations"
required:
- "money"
- "actualDistanceMiles"
- "fuelConsumptionLitresPer100Km"
- "arrivals"
properties:
money:
allOf:
-
"$ref": "#/components/schemas/Money"
nullable: true
actualDistanceMiles:
type: "string"
nullable: true
pattern: "^(0|[1-9][0-9]{0,8})(\\.[0-9]{1,3})?$"
fuelConsumptionLitresPer100Km:
type: "string"
nullable: true
pattern: "^(0|[1-9][0-9]{0,8})(\\.[0-9]{1,3})?$"
arrivals:
type: "array"
items:
"$ref": "#/components/schemas/Arrival"
SaveOperations:
type: "object"
x-java-record: "OwnedBusinessModels.SaveOperations"
required:
- "recordVersion"
- "idempotencyKey"
- "operations"
properties:
recordVersion:
type: "string"
pattern: "^(0|[1-9][0-9]*)$"
idempotencyKey:
type: "string"
minLength: 8
maxLength: 128
operations:
"$ref": "#/components/schemas/Operations"
reason:
type: "string"
nullable: true
maxLength: 1000
BindPhoto:
type: "object"
x-java-record: "OwnedBusinessModels.BindPhoto"
required:
- "recordVersion"
- "idempotencyKey"
- "fileId"
properties:
recordVersion:
type: "string"
pattern: "^(0|[1-9][0-9]*)$"
idempotencyKey:
type: "string"
minLength: 8
maxLength: 128
fileId:
type: "string"
pattern: "^[1-9][0-9]*$"
Gate:
type: "object"
x-java-record: "OwnedBusinessModels.Gate"
required:
- "allowed"
- "blockReason"
properties:
allowed:
type: "boolean"
blockReason:
type: "string"
nullable: true
Actions:
type: "object"
x-java-record: "OwnedBusinessModels.Actions"
required:
- "edit"
- "upload"
properties:
edit:
"$ref": "#/components/schemas/Gate"
upload:
"$ref": "#/components/schemas/Gate"
Origin:
type: "object"
x-java-record: "OwnedBusinessModels.Origin"
required:
- "warehouseId"
- "warehouseCode"
- "warehouseName"
properties:
warehouseId:
type: "string"
warehouseCode:
type: "string"
warehouseName:
type: "string"
Vehicle:
type: "object"
x-java-record: "OwnedBusinessModels.Vehicle"
required:
- "vehicleId"
- "plate"
- "ownership"
properties:
vehicleId:
type: "string"
plate:
type: "string"
ownership:
type: "string"
enum:
- "OWNED"
Driver:
type: "object"
x-java-record: "OwnedBusinessModels.Driver"
required:
- "name"
- "phone"
properties:
name:
type: "string"
nullable: true
phone:
type: "string"
nullable: true
Appointment:
type: "object"
x-java-record: "OwnedBusinessModels.Appointment"
required:
- "appointmentNo"
- "confirmationNo"
- "scheduledAt"
- "allocatedPackages"
properties:
appointmentNo:
type: "string"
confirmationNo:
type: "string"
nullable: true
scheduledAt:
type: "string"
nullable: true
format: "date-time"
allocatedPackages:
type: "string"
pattern: "^[1-9][0-9]*$"
Destination:
type: "object"
x-java-record: "OwnedBusinessModels.Destination"
required:
- "destinationKey"
- "warehouseCode"
- "warehouseName"
- "address"
- "appointments"
- "actualArrivedAt"
properties:
destinationKey:
type: "string"
warehouseCode:
type: "string"
nullable: true
warehouseName:
type: "string"
nullable: true
address:
type: "string"
nullable: true
appointments:
type: "array"
items:
"$ref": "#/components/schemas/Appointment"
actualArrivedAt:
type: "string"
nullable: true
format: "date-time"
Photo:
type: "object"
x-java-record: "OwnedBusinessModels.Photo"
required:
- "slot"
- "category"
- "fileId"
- "fileName"
- "contentType"
- "sizeBytes"
- "uploadedAt"
- "savedAt"
properties:
slot:
"$ref": "#/components/schemas/PhotoSlot"
category:
type: "string"
enum:
- "LOADING"
- "FUEL"
fileId:
type: "string"
fileName:
type: "string"
contentType:
type: "string"
enum:
- "image/jpeg"
- "image/png"
sizeBytes:
type: "string"
pattern: "^[1-9][0-9]*$"
uploadedAt:
type: "string"
savedAt:
type: "string"
nullable: true
format: "date-time"
description: "上传回执savedAt为空;绑定成功才填写savedAt。File类型分别为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO,平台0、原dispatch内部ID,最多10MiB。"
PhotoSummary:
type: "object"
x-java-record: "OwnedBusinessModels.PhotoSummary"
required:
- "loadingCount"
- "fuelCount"
properties:
loadingCount:
type: "integer"
minimum: 0
fuelCount:
type: "integer"
minimum: 0
Row:
type: "object"
x-java-record: "OwnedBusinessModels.Row"
required:
- "dispatchNo"
- "stage"
- "plannedDepartureAt"
- "loadingCompletedAt"
- "departedAt"
- "deliveryCompletedAt"
- "origin"
- "vehicle"
- "driver"
- "destinations"
- "actualPalletCount"
- "operations"
- "unitPrice"
- "recordVersion"
- "dispatchRecordVersion"
- "photoSummary"
- "actions"
- "sourceIssues"
properties:
dispatchNo:
type: "string"
stage:
"$ref": "#/components/schemas/Stage"
plannedDepartureAt:
type: "string"
format: "date-time"
loadingCompletedAt:
type: "string"
format: "date-time"
departedAt:
type: "string"
nullable: true
format: "date-time"
deliveryCompletedAt:
type: "string"
nullable: true
format: "date-time"
origin:
"$ref": "#/components/schemas/Origin"
vehicle:
"$ref": "#/components/schemas/Vehicle"
driver:
"$ref": "#/components/schemas/Driver"
destinations:
type: "array"
items:
"$ref": "#/components/schemas/Destination"
actualPalletCount:
type: "integer"
minimum: 0
nullable: true
operations:
"$ref": "#/components/schemas/Operations"
unitPrice:
type: "string"
nullable: true
pattern: "^(0|[1-9][0-9]{0,15})(\\.[0-9]{1,2})?$"
recordVersion:
type: "string"
pattern: "^(0|[1-9][0-9]*)$"
dispatchRecordVersion:
type: "string"
pattern: "^(0|[1-9][0-9]*)$"
photoSummary:
"$ref": "#/components/schemas/PhotoSummary"
actions:
"$ref": "#/components/schemas/Actions"
sourceIssues:
type: "array"
items:
type: "string"
Summary:
type: "object"
x-java-record: "OwnedBusinessModels.Summary"
required:
- "dispatchCount"
- "afterLoadingCount"
- "deliveringCount"
- "deliveredCount"
properties:
dispatchCount:
type: "integer"
minimum: 0
afterLoadingCount:
type: "integer"
minimum: 0
deliveringCount:
type: "integer"
minimum: 0
deliveredCount:
type: "integer"
minimum: 0
ListResult:
type: "object"
x-java-record: "OwnedBusinessModels.ListResult"
required:
- "records"
- "page"
- "size"
- "total"
- "summary"
properties:
records:
type: "array"
items:
"$ref": "#/components/schemas/Row"
page:
type: "integer"
minimum: 1
size:
type: "integer"
minimum: 1
maximum: 200
total:
type: "integer"
minimum: 0
summary:
"$ref": "#/components/schemas/Summary"
Cargo:
type: "object"
x-java-record: "OwnedBusinessModels.Cargo"
required:
- "sourceKey"
- "customerId"
- "customerName"
- "orderNo"
- "inboundCode"
- "palletNo"
- "destinationKey"
- "packages"
properties:
sourceKey:
type: "string"
customerId:
type: "string"
customerName:
type: "string"
nullable: true
orderNo:
type: "string"
nullable: true
inboundCode:
type: "string"
nullable: true
palletNo:
type: "string"
destinationKey:
type: "string"
packages:
type: "string"
Detail:
type: "object"
x-java-record: "OwnedBusinessModels.Detail"
required:
- "row"
- "dispatch"
- "cargo"
- "photos"
properties:
row:
"$ref": "#/components/schemas/Row"
dispatch:
"$ref": "./overseas-business-v1.yaml#/components/schemas/DispatchDetail"
cargo:
type: "array"
items:
"$ref": "#/components/schemas/Cargo"
photos:
type: "array"
items:
"$ref": "#/components/schemas/Photo"
History:
type: "object"
x-java-record: "OwnedBusinessModels.History"
required:
- "eventSource"
- "recordVersion"
- "dispatchRecordVersion"
- "action"
- "operatorName"
- "occurredAt"
- "reason"
- "operations"
- "photos"
properties:
eventSource:
type: "string"
enum:
- "DISPATCH"
- "OPERATIONS"
recordVersion:
type: "string"
pattern: "^(0|[1-9][0-9]*)$"
nullable: true
dispatchRecordVersion:
type: "string"
pattern: "^(0|[1-9][0-9]*)$"
nullable: true
action:
type: "string"
operatorName:
type: "string"
nullable: true
occurredAt:
type: "string"
format: "date-time"
reason:
type: "string"
nullable: true
operations:
allOf:
-
"$ref": "#/components/schemas/Operations"
nullable: true
photos:
type: "array"
items:
"$ref": "#/components/schemas/Photo"
description: "UNION ALL读取原排车真实流水与附加流水,一次排序分页;DISPATCH事件recordVersion=null、operations=null、photos=[];OPERATIONS事件dispatchRecordVersion=null。"
HistoryPage:
type: "object"
x-java-record: "OwnedBusinessModels.HistoryPage"
required:
- "records"
- "page"
- "size"
- "total"
properties:
records:
type: "array"
items:
"$ref": "#/components/schemas/History"
page:
type: "integer"
minimum: 1
size:
type: "integer"
minimum: 1
maximum: 200
total:
type: "integer"
minimum: 0
Command:
type: "object"
x-java-record: "OwnedBusinessModels.Command"
required:
- "dispatchNo"
- "action"
- "idempotencyKey"
- "state"
- "recordVersion"
- "replayed"
- "result"
- "errorCode"
- "retryAllowed"
properties:
dispatchNo:
type: "string"
action:
type: "string"
enum:
- "SAVE_OPERATIONS"
- "UPLOAD_PHOTO"
- "BIND_PHOTO"
idempotencyKey:
type: "string"
state:
type: "string"
enum:
- "SUCCEEDED"
- "PENDING"
- "UNKNOWN_RESULT"
- "REJECTED"
recordVersion:
type: "string"
pattern: "^(0|[1-9][0-9]*)$"
nullable: true
replayed:
type: "boolean"
result:
nullable: true
oneOf:
-
"$ref": "#/components/schemas/Detail"
-
"$ref": "#/components/schemas/Photo"
errorCode:
type: "string"
nullable: true
retryAllowed:
type: "boolean"
enum:
- false
@@ -48,13 +48,14 @@ paths:
available: {type: boolean} available: {type: boolean}
/api/platform/v1/last-leg/fleet/trips/calendar: /api/platform/v1/last-leg/fleet/trips/calendar:
get: get:
summary: 读取美国车次用车日历来源 summary: 读取美国真实整车排车日历来源
description: >- description: >-
仅PLATFORM且平台公共主体(tenantId=0),权限 platform:fleet:trip:list。from/to 是明确UTC时间点,已发车按实际发车时间、尚未发车按计划发车时间读取半开区间,最多44天; 仅PLATFORM且平台公共主体(tenantId=0),权限 platform:overseas-warehouse:dispatch:list,与排车列表及详情一致。from/to 是明确UTC时间点,已发车按实际发车时间、尚未发车按计划发车时间读取半开区间,最多44天;
该保护性窗口供界面最多42个当地日历日加时区边界使用,不扩大界面范围。 该保护性窗口供界面最多42个当地日历日加时区边界使用,不扩大界面范围。
仅出库派送用途(DELIVERY);上门取货入仓用途(PICKUP)不属于此日历排车来源。 仅出库派送用途(DELIVERY);上门取货入仓用途(PICKUP)不属于此日历排车来源。
最多2000条候选,超限明确拒绝并要求缩小日期范围,不分页扫描、不静默截断。 最多2000条候选,超限明确拒绝并要求缩小日期范围,不分页扫描、不静默截断。
一车一条,保留取消及完成状态;保留原计划与实际时间,已执行事实不随计划改期移动,不表示收货预约已确认。 仅关联ll_dispatch_order中ACTIVE真实主单,返回dispatchNo;独立旧车次和取消排车主单排除,一张排车主单一条。
保留原计划与实际时间,已执行事实不随计划改期移动,不表示收货预约已确认;日历跳排车详情,不使用内部车次号拼排车路径。
已知非美国仓排除;未知仓库/国家/地理IANA时区进入unresolvedRecords,不落入美国当地日槽。 已知非美国仓排除;未知仓库/国家/地理IANA时区进入unresolvedRecords,不落入美国当地日槽。
一次车次查询加一次仓库批量资料请求,包含停用仓历史资料,资料服务失败返回错误而非空日历。 一次车次查询加一次仓库批量资料请求,包含停用仓历史资料,资料服务失败返回错误而非空日历。
parameters: parameters:
@@ -358,10 +359,10 @@ components:
schemas: schemas:
TripCalendarEntry: TripCalendarEntry:
type: object type: object
required: [sourceType, tripNo, status, plannedDepartureAt, expectedDriverEndAt, stops, incompleteReasons] required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, incompleteReasons]
properties: properties:
sourceType: {type: string, enum: [TRIP_PLAN]} sourceType: {type: string, enum: [DISPATCH]}
tripNo: {type: string} dispatchNo: {type: string, description: 真实整车主单排车号,用于排车详情路由}
status: {type: string, enum: [PLANNED, IN_TRANSIT, COMPLETED, CANCELLED]} status: {type: string, enum: [PLANNED, IN_TRANSIT, COMPLETED, CANCELLED]}
originWarehouseId: {type: string, nullable: true} originWarehouseId: {type: string, nullable: true}
originWarehouseCode: {type: string, nullable: true} originWarehouseCode: {type: string, nullable: true}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.dispatch.fleet;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.port.DispatchTypes;
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*;
/** 运营组合读取必须保留原详情提供者的身份和拒绝语义。 */
class DispatchDetailReadAdapterTest {
/** 不吞原排车资格失败,不切换操作者或降级返回空详情。 */
@Test
void preservesOriginalIdentityAndReadRejection() {
var original = mock(DispatchAggregateService.class);
var actor = new DispatchContracts.Actor("owned-business-reader", DispatchTypes.ActorMode.PLATFORM, null);
var failure = new IllegalArgumentException("原单不可读取");
when(original.detail(actor, "DS-OWNED-READ")).thenThrow(failure);
var adapter = new DispatchDetailReadAdapter(original);
var actual = assertThrows(IllegalArgumentException.class, () -> adapter.detail(actor, "DS-OWNED-READ"));
org.junit.jupiter.api.Assertions.assertSame(failure, actual);
verify(original).detail(actor, "DS-OWNED-READ");
verifyNoMoreInteractions(original);
}
}
@@ -268,12 +268,25 @@ public class DispatchAggregatePersistenceTest {
var measurements=preview.pallets().stream().map(p->new DispatchContracts.LoadingMeasurement(p.warehousePalletId(),1, var measurements=preview.pallets().stream().map(p->new DispatchContracts.LoadingMeasurement(p.warehousePalletId(),1,
p.warehousePalletId()==501L?new BigDecimal("100"):new BigDecimal("200"),p.warehousePalletId()==501L?BigDecimal.ONE:new BigDecimal("2"),p.deliveryIndex())).toList(); p.warehousePalletId()==501L?new BigDecimal("100"):new BigDecimal("200"),p.warehousePalletId()==501L?BigDecimal.ONE:new BigDecimal("2"),p.deliveryIndex())).toList();
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"explicit-loaded",preview.manifestHash(),measurements,"实际核对"); var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"explicit-loaded",preview.manifestHash(),measurements,"实际核对");
// 捕获失败必须回滚同一原生装车事务,不能留下装车后状态或成功命令。
org.mockito.Mockito.doThrow(new IllegalStateException("ownership-capture-failed")).doNothing()
.when(f.ownedBusinessOwnership).capture(anyLong(),org.mockito.ArgumentMatchers.nullable(Long.class),
anyString(),anyString(),any(java.time.Instant.class));
assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request)))
.isInstanceOf(IllegalStateException.class).hasMessage("ownership-capture-failed");
assertThat(f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING");
assertThat(f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isEqualTo(approved.recordVersion());
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='explicit-loaded'",Long.class)).isZero();
var loaded=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request)); var loaded=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request));
assertThat(loaded.stage()).isEqualTo(Stage.AFTER_LOADING);assertThat(loaded.result()).isInstanceOf(DispatchResults.Loaded.class); assertThat(loaded.stage()).isEqualTo(Stage.AFTER_LOADING);assertThat(loaded.result()).isInstanceOf(DispatchResults.Loaded.class);
assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED"); assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
var replay=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request)); var replay=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request));
assertThat(replay.replayed()).isTrue();assertThat(replay.result()).isEqualTo(loaded.result()); assertThat(replay.replayed()).isTrue();assertThat(replay.result()).isEqualTo(loaded.result());
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2); assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2);
org.mockito.Mockito.verify(f.ownedBusinessOwnership,org.mockito.Mockito.times(2)).capture(anyLong(),
eq(context.resources().vehicle().vehicleId()),eq(context.resources().vehicle().mode().name()),
anyString(),any(java.time.Instant.class));
org.mockito.Mockito.verifyNoMoreInteractions(f.ownedBusinessOwnership);
} }
/** 合计相等但某项漏拣或归属缺失时,公共端口不能报告已核平。 */ /** 合计相等但某项漏拣或归属缺失时,公共端口不能报告已核平。 */
@@ -444,6 +457,8 @@ public class DispatchAggregatePersistenceTest {
/** 实际MyBatis和Spring本机内存事务;不使用返回成功的仓储替身。 */ /** 实际MyBatis和Spring本机内存事务;不使用返回成功的仓储替身。 */
public static final class Fixture { public static final class Fixture {
final com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture ownedBusinessOwnership=
mock(com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture.class);
public final JdbcTemplate db; public final TransactionTemplate tx; public final DispatchAggregateService service; public final JdbcTemplate db; public final TransactionTemplate tx; public final DispatchAggregateService service;
public final DataSourceTransactionManager transactions;public final ObjectMapper json;public final SqlSessionTemplate session; public final DataSourceTransactionManager transactions;public final ObjectMapper json;public final SqlSessionTemplate session;
public final DispatchAggregateRepository repository;public final DispatchExecutionRepository executionReads;public final FleetTripExecutionService fleet; public final DispatchAggregateRepository repository;public final DispatchExecutionRepository executionReads;public final FleetTripExecutionService fleet;
@@ -522,6 +537,7 @@ public class DispatchAggregatePersistenceTest {
new DispatchSourceRepository(session.getMapper(DispatchCandidateMapper.class),session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)),rules,fleet, new DispatchSourceRepository(session.getMapper(DispatchCandidateMapper.class),session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)),rules,fleet,
new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(json)),new DispatchCommandHasher(json),json); new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(json)),new DispatchCommandHasher(json),json);
org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class)); org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class));
org.springframework.test.util.ReflectionTestUtils.setField(service,"ownedBusinessOwnership",ownedBusinessOwnership);
org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service); org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service);
org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class))); org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)));
executionReads=new DispatchExecutionRepository(session.getMapper(DispatchExecutionReadMapper.class)); executionReads=new DispatchExecutionRepository(session.getMapper(DispatchExecutionReadMapper.class));
@@ -148,6 +148,33 @@ class DispatchMergedRelabelPersistenceTest {
var created=f.create("actual-plan",group.sources().stream().map(s->s.selection()).toList());assertThat(created.counts().palletCount()).isEqualTo(1);assertThat(created.counts().packageCount()).isEqualTo(4); var created=f.create("actual-plan",group.sources().stream().map(s->s.selection()).toList());assertThat(created.counts().palletCount()).isEqualTo(1);assertThat(created.counts().packageCount()).isEqualTo(4);
} }
/** 无效实托不成为候选或跨申请扩组锚点;同申请缺失的组成仍明确阻断建单。 */
@ParameterizedTest @ValueSource(longs={0,-1})
void invalidPalletIdentityDoesNotCrashOrConnectUnrelatedApplications(Long invalidPallet) throws Exception {
var f=new Merged(false);
for(long id:List.of(102L,103L))f.insert("ll_inventory_position","id",id,"customer_id","101","balance_id",601,
"overseas_warehouse_id","9","pallet_id",invalidPallet,"pallet_no","","batch_no","MISSING-"+id,
"warehouse_location_code","LOC-1","hold_packages",1);
f.insert("ll_relabel_item","id",303,"application_id",201,"customer_id","101","product_name_zh","待核对来源",
"total_package_count",1,"notified_package_count",1,"carton_mark","OLD-MISSING","fba_carton_mark","NEW-MISSING",
"operation_type","CARTON_LABEL","source_inventory_balance_id",601,"source_inventory_position_id",102,
"source_inbound_order_id",7201,"source_inbound_order_no","ORIGIN-MISSING","source_inbound_code","CODE-MISSING",
"destination_fba_warehouse_code","ONT8");
f.application(203,"RL-UNRELATED","101",9003,304,103,1,"UNRELATED");
var candidates=f.rules.wholeCandidates("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null));
var groups=f.rules.selectionGroups(candidates);
assertThat(candidates).hasSize(2).allSatisfy(row->assertThat(row.warehousePalletId()).isEqualTo(501L));
var group=groups.get("RELABEL:RL-A:501");
assertThat(group.complete()).isFalse();
assertThat(group.blockReasons()).anyMatch(reason->reason.contains("RL-A")&&reason.contains("明细当前位置缺失"));
assertThat(group.sources()).extracting(DispatchPlanningModels.SelectionGroupSource::sourceBusinessNo)
.containsExactlyInAnyOrder("RL-A","RL-B");
assertThat(group.pallets()).hasSize(1);
assertThatThrownBy(()->f.create("invalid-pallet",group.sources().stream().map(s->s.selection()).toList()))
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
assertThat(f.count("ll_dispatch_order")).isZero();assertThat(f.count("ll_dispatch_pallet_lock")).isZero();
}
/** 只隔离测量和车队外部目录;来源提供者、候选、库存与所有排车Mapper均为实际实现。 */ /** 只隔离测量和车队外部目录;来源提供者、候选、库存与所有排车Mapper均为实际实现。 */
private static final class Merged { private static final class Merged {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 全车请求键唯一,原操作者、action、版本、载荷缺一不可。 */
class OwnedBusinessCommandTest {
/** 已有键不能按另一个operator维度重新执行或读取结果。 */
@Test void anotherActorCannotBorrowOriginalKey() {
var repo=mock(OwnedBusinessRepository.class);var service=new OwnedBusinessCommandService(repo,OwnedBusinessFixtures.codec());
var row=service.create(1,OwnedBusinessFixtures.actor(),"original-key","SAVE_OPERATIONS",0L,"original","SUCCEEDED");
when(repo.command(1,"original-key")).thenReturn(row);
var denied=assertThrows(ResponseStatusException.class,()->service.existing(1,"original-key",new Actor("other","其他测试账号",Set.of())));
assertEquals(403,denied.getStatusCode().value());verify(repo,never()).command(any());
}
/** 同一请求原版本0合法,改载荷或改版本不得借用原成功。 */
@Test void fullOriginalIdentityIsRequiredForReplay() {
var service=new OwnedBusinessCommandService(mock(OwnedBusinessRepository.class),OwnedBusinessFixtures.codec());
var row=service.create(1,OwnedBusinessFixtures.actor(),"original-key","SAVE_OPERATIONS",0L,"original","SUCCEEDED");
assertDoesNotThrow(()->service.same(row,"SAVE_OPERATIONS",0L,"original"));
assertThrows(BizException.class,()->service.same(row,"SAVE_OPERATIONS",1L,"original"));
assertThrows(BizException.class,()->service.same(row,"SAVE_OPERATIONS",0L,"changed"));
assertThrows(BizException.class,()->service.same(row,"BIND_PHOTO",0L,"original"));
}
/** 未知上传只有原状态,不创建第二个File调用意图。 */
@Test void unknownUploadReservationDoesNotBecomeNewWork() {
var repo=mock(OwnedBusinessRepository.class);var service=new OwnedBusinessCommandService(repo,OwnedBusinessFixtures.codec());
var row=service.create(1,OwnedBusinessFixtures.actor(),"upload-key","UPLOAD_PHOTO",null,"original","UNKNOWN_RESULT");
when(repo.command(1,"upload-key")).thenReturn(row);
var reservation=service.reserveUpload(1,OwnedBusinessFixtures.actor(),"upload-key","original");
assertFalse(reservation.created());assertEquals("UNKNOWN_RESULT",reservation.command().getStatus());verify(repo,never()).command(any());
}
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
/** 本域消费者的文件归属及格式回归,不重复File提供者全量测试。 */
class OwnedBusinessFileGatewayTest {
/** 图片文本伪装、PDF及空内容均须在File调用前拒绝。 */
@Test void invalidImageSignaturesAndMimeAreRejected() {
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.png","image/png","not-image".getBytes())));
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.pdf","application/pdf","%PDF".getBytes())));
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.jpg","image/jpeg",new byte[0])));
}
/** 同一原dispatch内部ID、平台0与分类都匹配才接受元数据。 */
@Test void metadataCannotCrossCustomerDispatchOrCategory() {
var good=metadata(0L,"1","OVERSEAS_DISPATCH_LOADING_PHOTO");
assertEquals(9,HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",good),1,PhotoSlot.LOADING_SCENE,9L).fileId());
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",metadata(101L,"1","OVERSEAS_DISPATCH_LOADING_PHOTO")),1,PhotoSlot.LOADING_SCENE,9L));
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",metadata(0L,"2","OVERSEAS_DISPATCH_LOADING_PHOTO")),1,PhotoSlot.LOADING_SCENE,9L));
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",good),1,PhotoSlot.FUEL_START,9L));
}
/** File所有权使用原dispatch而非trip或客户,照片框不能改变类型。 */
private HttpOwnedBusinessFileGateway.Metadata metadata(Long tenant,String businessId,String type) {
return new HttpOwnedBusinessFileGateway.Metadata(9L,tenant,type,businessId,"actual.png","image/png",20L,LocalDateTime.parse("2026-10-20T07:30:00"));
}
}
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.CargoRow;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
/** 仅本地测试夹具,不操作业务环境或生成运行样本。 */
final class OwnedBusinessFixtures {
/** 夹具容器不允许实例化。 */
private OwnedBusinessFixtures() {}
/** Jackson包含实际时间类型,避免把日期当假字符串。 */
static OwnedBusinessSupport codec(){return new OwnedBusinessSupport(new ObjectMapper().findAndRegisterModules());}
/** 平台列表、编辑、上传权限保持独立。 */
static Actor actor(){return new Actor("operator-1","TEST操作员",Set.of(LIST,EDIT,UPLOAD));}
/** 装车完成但未发车,证明不依赖DEPART历史。 */
static Source source() {
var row=new Source();row.setId(1L);row.setDispatchNo("DS-OWN-ONE");row.setStatus("ACTIVE");row.setStage("AFTER_LOADING");
row.setRecordVersion(8L);row.setOverseasWarehouseId("9");row.setOverseasWarehouseCode("WH-9");row.setOverseasWarehouseName("测试仓");
row.setPlannedDepartureAt(LocalDateTime.parse("2026-10-20T08:00:00"));row.setLoadingCompletedAt(LocalDateTime.parse("2026-10-20T07:30:00"));
row.setLoadingCompletionHash("actual-load");row.setOwnedLoadingCompletionHash("actual-load");row.setOwnedLoadingCompletedAt(row.getLoadingCompletedAt());
row.setOwnedResourceMode("MASTER");row.setOwnedVehicleOwnership("OWNED");row.setOwnedVehicleId(7L);row.setOwnedVehicleRecordVersion(3L);
row.setResourcesJson("""
{"vehicle":{"mode":"MASTER","vehicleId":7,"plate":"TEST-OWN-7","carrierName":null,"vehicleStatus":"IN_SERVICE",
"capacity":{"palletSpaces":10,"maxLoadKg":1000,"maxVolumeCbm":20},"evidenceFileIds":[]},
"driver":{"mode":"MASTER","driverId":8,"name":"测试司机","phone":null,"evidence":null}}
""");
row.setLoadingConfirmationJson("""
{"manifestHash":"actual-manifest","pallets":[{"warehousePalletId":31,"palletSpaces":3,"weightKg":100,"volumeCbm":2,"deliveryIndex":1}],
"operatorId":"picker","confirmedAt":"2026-10-20T07:30:00Z"}
""");return row;
}
/** 两个客户逻辑来源共享一个真实物理托盘,不合并客户归属。 */
static List<CargoRow> cargo() {
var first=item(11L,"101","IN-A");var second=item(12L,"102","IN-B");return List.of(first,second);
}
/** 来源事实的目的地键与正式排车提供者一致。 */
private static CargoRow item(long id,String customer,String inbound) {
var i=new CargoRow();i.setId(id);i.setDispatchOrderId(1L);i.setCustomerId(customer);i.setCustomerName("测试客户"+customer);
i.setSourceKey("SOURCE:"+inbound);i.setOrderNo("ORDER-"+inbound);i.setInboundCode(inbound);i.setWarehousePalletId(31L);i.setPalletNo("PAL-31");
i.setDestinationType("FBA");i.setDestinationWarehouseCode("ONT8");i.setDestinationWarehouseName("ONT8仓");i.setPackageCount(1);return i;
}
}
@@ -0,0 +1,66 @@
package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessOwnershipService;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Ownership;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.*;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 归属来自真实装车时车辆,不从GET和当前主档补造历史。 */
class OwnedBusinessOwnershipTest {
/** 自有车辆无论承运何种货源,都按真实主档归属捕获。 */
@Test void capturesRealOwnedVehicleAndOriginalLoadTime() {
var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class);
when(vehicles.findById(7)).thenReturn(Optional.of(vehicle("OWNED",3)));
var service=new OwnedBusinessOwnershipService(repo,vehicles);var at=Instant.parse("2026-10-20T07:30:00Z");
service.capture(1,7L,"MASTER","load-1",at);
var saved=ArgumentCaptor.forClass(Ownership.class);verify(repo).capture(saved.capture());
assertEquals("OWNED",saved.getValue().getVehicleOwnership());assertEquals(3L,saved.getValue().getVehicleRecordVersion());
assertEquals(LocalDateTime.parse("2026-10-20T07:30:00"),saved.getValue().getLoadingCompletedAt());
verify(vehicles,never()).lockById(anyLong());
}
/** 临时外配原模式EXTERNAL原样保存,不伪造车辆档案。 */
@Test void externalHasNoMasterIdOrVersion() {
var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class);
new OwnedBusinessOwnershipService(repo,vehicles).capture(1,null,"EXTERNAL","load",Instant.parse("2026-10-20T07:30:00Z"));
var saved=ArgumentCaptor.forClass(Ownership.class);verify(repo).capture(saved.capture());
assertEquals("EXTERNAL",saved.getValue().getResourceMode());assertEquals("DISPATCHED",saved.getValue().getVehicleOwnership());
assertNull(saved.getValue().getVehicleRecordVersion());verifyNoInteractions(vehicles);
}
/** MASTER只是档案选择,不代表车辆自有,更不代表货源自有。 */
@Test void masterVehicleCanBeDispatchedAndMustKeepThatOwnership() {
var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class);
when(vehicles.findById(7)).thenReturn(Optional.of(vehicle("DISPATCHED",4)));
new OwnedBusinessOwnershipService(repo,vehicles).capture(1,7L,"MASTER","load",Instant.parse("2026-10-20T07:30:00Z"));
var saved=ArgumentCaptor.forClass(Ownership.class);verify(repo).capture(saved.capture());
assertEquals("DISPATCHED",saved.getValue().getVehicleOwnership());assertEquals("MASTER",saved.getValue().getResourceMode());
assertEquals(4L,saved.getValue().getVehicleRecordVersion());
}
/** 重放沿原证据,不读取后来可能变化的车辆归属。 */
@Test void matchingCaptureReplaysWithoutReadingCurrentVehicle() {
var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class);var owned=new Ownership();
owned.setDispatchId(1L);owned.setVehicleId(7L);owned.setResourceMode("MASTER");owned.setVehicleOwnership("OWNED");
owned.setLoadingCompletionHash("load");owned.setLoadingCompletedAt(LocalDateTime.parse("2026-10-20T07:30:00"));
when(repo.ownership(1)).thenReturn(owned);
new OwnedBusinessOwnershipService(repo,vehicles).capture(1,7L,"MASTER","load",Instant.parse("2026-10-20T07:30:00Z"));
verifyNoInteractions(vehicles);verify(repo,never()).capture(any());
assertThrows(BizException.class,()->new OwnedBusinessOwnershipService(repo,vehicles).capture(1,7L,"MASTER","different",Instant.parse("2026-10-20T07:30:00Z")));
}
/** MASTER没有真实车辆时阻断原装车事务,不退回外配。 */
@Test void rejectsMissingVehicleInsteadOfInventingOwnership() {
var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class);
when(vehicles.findById(7)).thenReturn(Optional.empty());
assertThrows(BizException.class,()->new OwnedBusinessOwnershipService(repo,vehicles).capture(1,7L,"MASTER","load",Instant.parse("2026-10-20T07:30:00Z")));
verify(repo,never()).capture(any());
}
/** 真实车辆快照由端口返回,测试不依赖车辆存储实现。 */
private VehicleRepository.VehicleSnapshot vehicle(String ownership,long version) {
return new VehicleRepository.VehicleSnapshot(7,"TEST-7","TRUCK",null,0,null,null,"US",ownership,"IN_SERVICE",null,version,null,LocalDateTime.parse("2026-10-20T07:30:00"),10,null,null);
}
}
@@ -0,0 +1,230 @@
package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.lastleg.fleet.ownedbusiness.mapper.*;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.baiyue.common.core.exception.BizException;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.junit.jupiter.api.*;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.transaction.support.TransactionTemplate;
import java.nio.file.*;
import java.util.*;
import java.util.regex.Pattern;
import java.time.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 本机H2/MyBatis真实四表回归,不调用环境或复用外部数据库。 */
class OwnedBusinessPersistenceTest {
JdbcTemplate jdbc;
OwnedBusinessRepository repository;
OwnedBusinessSupport codec;
OwnedBusinessProjection projection;
OwnedBusinessRecordService service;
OwnedBusinessQueryService queries;
OwnedBusinessCommandService commands;
DataSourceTransactionManager manager;
/** 精确读取根新增4表基线,测试只适配H2方言,不重写业务列或约束。 */
@BeforeEach void setup() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:owned-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa","");
jdbc=new JdbcTemplate(ds);manager=new DataSourceTransactionManager(ds);
jdbc.execute("""
CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(128),trip_id BIGINT,status VARCHAR(32),
stage VARCHAR(32),record_version BIGINT,planned_departure_at DATETIME(3),loading_completed_at DATETIME(3),departed_at DATETIME(3),delivery_completed_at DATETIME(3),
loading_completion_hash VARCHAR(128),loading_confirmation_json LONGTEXT,resources_json LONGTEXT,
vehicle_plate VARCHAR(64),overseas_warehouse_id VARCHAR(64),overseas_warehouse_code VARCHAR(64),overseas_warehouse_name VARCHAR(128))
""");
jdbc.execute("""
CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(64),
customer_name VARCHAR(128),source_key VARCHAR(128),order_no VARCHAR(128),inbound_code VARCHAR(128),
pallet_no VARCHAR(128),warehouse_pallet_id BIGINT,destination_type VARCHAR(32),
destination_warehouse_code VARCHAR(128),destination_warehouse_name VARCHAR(128),fba_warehouse_code VARCHAR(128),
delivery_address VARCHAR(256),postal_code VARCHAR(64),package_count INT)
""");
jdbc.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,customer_id VARCHAR(64),batch_key VARCHAR(128),destination_key VARCHAR(128),package_count INT)");
jdbc.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR(64),confirmation_no VARCHAR(64),appointment_start_utc DATETIME(3))");
jdbc.execute("""
CREATE TABLE ll_dispatch_history(id BIGINT PRIMARY KEY,dispatch_id BIGINT,record_version BIGINT,action VARCHAR(32),
operator_id VARCHAR(64),reason VARCHAR(1000),snapshot_json LONGTEXT,occurred_at DATETIME(3))
""");
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String suffix:List.of("ownership","record","command","history")) {
var matcher=Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_"+suffix+" \\(.*?;").matcher(schema);
assertTrue(matcher.find(),"缺少根维护的"+suffix+"表");
String ddl=matcher.group().replace(" JSON"," LONGTEXT").replace(" COLLATE utf8mb4_bin","").replaceFirst("(?s) ENGINE=InnoDB.*?;$",";");
jdbc.execute(ddl);
}
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
config.addMapper(OwnedBusinessMapper.class);config.addMapper(OwnedBusinessQueryMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var session=new SqlSessionTemplate(Objects.requireNonNull(factory.getObject()));
repository=new OwnedBusinessRepository(session.getMapper(OwnedBusinessMapper.class),session.getMapper(OwnedBusinessQueryMapper.class));
codec=OwnedBusinessFixtures.codec();projection=new OwnedBusinessProjection(codec);
commands=new OwnedBusinessCommandService(repository,codec);queries=mock(OwnedBusinessQueryService.class);
var source=OwnedBusinessFixtures.source();
when(queries.requireOwned("DS-OWN-ONE")).thenReturn(source);
when(queries.detailForWrite(any(),any())).thenAnswer(call->new Detail(
projection.row(source,repository.record(1),repository.cargo(List.of(1L)),repository.batches(List.of(1L)),call.getArgument(0)),
null,List.of(),projection.photos(repository.record(1))));
service=tx(new OwnedBusinessRecordService(repository,queries,projection,commands,codec));
jdbc.update("""
INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,status,stage,record_version,planned_departure_at,
loading_completed_at,loading_completion_hash,loading_confirmation_json,resources_json,vehicle_plate,
overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name)
VALUES(1,'DS-OWN-ONE',2,'ACTIVE','AFTER_LOADING',8,?,?,?,?,?,'TEST-OWN-7','9','WH-9','测试仓')
""",source.getPlannedDepartureAt(),source.getLoadingCompletedAt(),source.getLoadingCompletionHash(),source.getLoadingConfirmationJson(),source.getResourcesJson());
for(var item:OwnedBusinessFixtures.cargo())jdbc.update("""
INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,customer_name,source_key,order_no,inbound_code,
pallet_no,warehouse_pallet_id,destination_type,destination_warehouse_code,destination_warehouse_name,package_count)
VALUES(?,1,?,?,?,?,?,?,31,'FBA','ONT8','ONT8仓',1)
""",item.getId(),item.getCustomerId(),item.getCustomerName(),item.getSourceKey(),item.getOrderNo(),item.getInboundCode(),item.getPalletNo());
jdbc.update("INSERT INTO ll_dispatch_history VALUES(99,1,8,'LOADING_COMPLETE','picker',NULL,'{}',?)",source.getLoadingCompletedAt());
var ownership=new OwnedBusinessRows.Ownership();ownership.setDispatchId(1L);ownership.setVehicleId(7L);
ownership.setResourceMode("MASTER");ownership.setVehicleOwnership("OWNED");ownership.setVehicleRecordVersion(3L);
ownership.setOwnershipCapturedAt(source.getLoadingCompletedAt());ownership.setLoadingCompletedAt(source.getLoadingCompletedAt());ownership.setLoadingCompletionHash("actual-load");
repository.capture(ownership);
}
/** 明确NULL更新真实落库,原主单版本、货物和两个客户完全不改。 */
@Test void savesThenClearsNullableValuesWithRealMapperCas() {
var first=request("save-money-first",0,new Money("CNY","30"),"12.5","18.6",List.of(new Arrival("FBA:ONT8","2026-10-20T16:00:00+08:00")));
var saved=service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",first);
assertEquals("1",saved.row().recordVersion());assertEquals("30.00",saved.row().unitPrice());
assertEquals("18.600",repository.record(1).getFuelConsumptionLitresPer100Km().toPlainString());
assertEquals("2026-10-20T08:00:00Z",codec.list(repository.record(1).getArrivalsJson(),Arrival.class).get(0).actualArrivedAt());
service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request("clear-money-now",1,null,null,null,List.of()));
var cleared=repository.record(1);assertNull(cleared.getCurrency());assertNull(cleared.getTotalAmount());
assertNull(cleared.getActualDistanceMiles());assertNull(cleared.getFuelConsumptionLitresPer100Km());assertEquals(2L,cleared.getRecordVersion());
assertEquals(8L,jdbc.queryForObject("SELECT record_version FROM ll_dispatch_order WHERE id=1",Long.class));
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class));
}
/** 真实基线CHECK必须拒绝SQL三值逻辑的UNKNOWN漏洞,同时保留未登记和明确零。 */
@Test void moneyCheckRejectsNullCurrencyWithAmountAndAcceptsNullPairAndZero() {
var actor=OwnedBusinessFixtures.actor();
service.save(actor,"DS-OWN-ONE",request("money-null-pair",0,null,null,null,List.of()));
assertNull(repository.record(1).getCurrency());assertNull(repository.record(1).getTotalAmount());
assertThrows(org.springframework.dao.DataIntegrityViolationException.class,
()->jdbc.update("UPDATE ll_fleet_owned_business_record SET currency=NULL,total_amount=1 WHERE dispatch_id=1"));
assertNull(repository.record(1).getCurrency());assertNull(repository.record(1).getTotalAmount());
service.save(actor,"DS-OWN-ONE",request("money-zero-value",1,new Money("CNY","0"),null,null,List.of()));
assertEquals("CNY",repository.record(1).getCurrency());assertEquals(0,repository.record(1).getTotalAmount().signum());
service.save(actor,"DS-OWN-ONE",request("money-clear-again",2,null,null,null,List.of()));
assertNull(repository.record(1).getCurrency());assertNull(repository.record(1).getTotalAmount());
}
/** 原成功载荷重放返回原版本,不能用新版本或不同操作者另起命令。 */
@Test void replayAndStaleVersionDoNotCreateExtraRecordsOrHistory() {
var first=request("first-operation",0,new Money("USD","0"),null,null,List.of());
assertEquals("1",service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",first).row().recordVersion());
assertEquals("1",service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",first).row().recordVersion());
assertThrows(BizException.class,()->service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request("stale-operation",0,null,null,null,List.of())));
assertThrows(BizException.class,()->service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request("first-operation",0,new Money("USD","1"),null,null,List.of())));
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class));
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_history",Integer.class));
}
/** 非本车目的地和不规范单位输入不能产生附加行或命令。 */
@Test void rejectsForeignDestinationBeforeAnyWrite() {
assertThrows(BizException.class,()->service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",
request("foreign-arrival",0,null,null,null,List.of(new Arrival("FBA:OTHER","2026-10-20T08:00:00Z")))));
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class));
}
/** 真绑定CAS、原键重放及替换后的历史引用均沿真实4表,不改变装车事实。 */
@Test void photoBindingKeepsCurrentAndHistoricalAuthorizationSeparate() {
var first=new Photo(PhotoSlot.LOADING_SCENE,"LOADING","9","TEST-one.png","image/png","9","2026-10-20T07:30:00",null);
var original=new BindPhoto("0","bind-original","9");
var bound=service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,original,first);
assertEquals("1",bound.row().recordVersion());assertNotNull(bound.photos().get(0).savedAt());
assertEquals("保存装车现场照片",repository.revision(1,1).getReason());
assertEquals("1",service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,original,first).row().recordVersion());
var replacement=new Photo(PhotoSlot.LOADING_SCENE,"LOADING","10","TEST-two.png","image/png","9","2026-10-20T07:31:00",null);
service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,new BindPhoto("1","bind-replacement","10"),replacement);
assertEquals("10",projection.photos(repository.record(1)).get(0).fileId());
assertEquals("9",codec.read(repository.revision(1,1).getSnapshotJson(),Snapshot.class).photos().get(0).fileId());
assertEquals("保存装车现场照片",repository.revision(1,2).getReason());
var file=mock(com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway.class);
when(file.content(1,PhotoSlot.LOADING_SCENE,9)).thenReturn(new byte[]{1,2});
var photoService=new OwnedBusinessPhotoService(queries,service,repository,commands,projection,codec,file);
assertThrows(BizException.class,()->photoService.content(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,9,null));
assertArrayEquals(new byte[]{1,2},photoService.content(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,9,1L).content());
assertEquals(8L,jdbc.queryForObject("SELECT record_version FROM ll_dispatch_order WHERE id=1",Long.class));
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_history",Integer.class));
}
/** 原流水与附加流水用一次真实SQL分页,版本域不混用。 */
@Test void historyMergesOriginalDispatchWithoutDuplicatingIt() {
service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request("history-register",0,null,null,"0",List.of()));
var realQueries=new OwnedBusinessQueryService(repository,projection,codec,mock(DispatchDetailReader.class));
var page=realQueries.history(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",1,20);
assertEquals(2,page.total());assertEquals(2,page.records().size());
var original=page.records().stream().filter(r->r.eventSource().equals("DISPATCH")).findFirst().orElseThrow();
assertEquals("8",original.dispatchRecordVersion());assertNull(original.recordVersion());assertNull(original.operations());assertEquals(List.of(),original.photos());
var added=page.records().stream().filter(r->r.eventSource().equals("OPERATIONS")).findFirst().orElseThrow();
assertEquals("1",added.recordVersion());assertNull(added.dispatchRecordVersion());
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history",Integer.class));
}
/** 当前页不是统计范围,装车后无DEPART也能入选;GET不建附加记录。 */
@Test void listUsesFilteredSourceAndLeavesVirtualZeroUnwritten() {
var realQueries=new OwnedBusinessQueryService(repository,projection,codec,mock(DispatchDetailReader.class));
var list=realQueries.list(OwnedBusinessFixtures.actor(),new Query(1,20,"2026-10-20T00:00:00Z","2026-10-21T00:00:00Z","ONT8",null,"9"));
assertEquals(1,list.total());assertEquals(1,list.summary().afterLoadingCount());assertEquals(1,list.summary().dispatchCount());
assertEquals("0",list.records().get(0).recordVersion());assertEquals(1,list.records().get(0).actualPalletCount());
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
var empty=realQueries.list(OwnedBusinessFixtures.actor(),new Query(1,20,"2026-10-20T08:00:00Z","2026-10-20T08:00:01Z",null,null,null));
assertEquals(1,empty.total());
var excluded=realQueries.list(OwnedBusinessFixtures.actor(),new Query(1,20,null,"2026-10-20T08:00:00Z",null,null,null));
assertEquals(0,excluded.total());
}
/** 原车辆归属捕获与调用方事务同回滚,不留下假的历史所有权。 */
@Test void ownershipCaptureRollsBackWithParentTransaction() {
jdbc.update("DELETE FROM ll_fleet_owned_business_ownership");
var vehicles=mock(VehicleRepository.class);
when(vehicles.findById(7)).thenReturn(Optional.of(new VehicleRepository.VehicleSnapshot(7,"TEST-7","TRUCK",null,0,null,null,"US","OWNED","IN_SERVICE",null,3,null,LocalDateTime.now(),10,null,null)));
var capture=tx(new OwnedBusinessOwnershipService(repository,vehicles));
assertThrows(IllegalStateException.class,()->new TransactionTemplate(manager).execute(status->{
capture.capture(1,7L,"MASTER","actual-load",Instant.parse("2026-10-20T07:30:00Z"));throw new IllegalStateException("模拟主装车回滚");
}));
assertNull(repository.ownership(1));verify(vehicles,never()).lockById(anyLong());
}
/** 两个真实数据库事务争用初始0版本,必须只有一个命令及历史成功。 */
@Test void concurrentZeroVersionWritesHaveExactlyOneWinner() throws Exception {
var pool=java.util.concurrent.Executors.newFixedThreadPool(2);
var start=new java.util.concurrent.CountDownLatch(1);
try {
var first=pool.submit(()->compete(start,"concurrent-first"));
var second=pool.submit(()->compete(start,"concurrent-second"));
start.countDown();
boolean a=first.get(10,java.util.concurrent.TimeUnit.SECONDS),b=second.get(10,java.util.concurrent.TimeUnit.SECONDS);
assertNotEquals(a,b);assertEquals(1L,repository.record(1).getRecordVersion());
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class));
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_history",Integer.class));
assertEquals(8L,jdbc.queryForObject("SELECT record_version FROM ll_dispatch_order WHERE id=1",Long.class));
} finally {pool.shutdownNow();}
}
/** 只把真实版本冲突判为竞争失败,SQL和基础设施异常必须让回归失败。 */
private boolean compete(java.util.concurrent.CountDownLatch start,String key) throws Exception {
if(!start.await(5,java.util.concurrent.TimeUnit.SECONDS))throw new IllegalStateException("并发测试起点超时");
try{service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request(key,0,new Money("CNY","1"),null,null,List.of()));return true;}
catch(BizException error){if(error.getResultCode()!=com.baiyue.common.core.api.ResultCode.CONFLICT)throw error;return false;}
}
/** 基于实际数据库事务代理调用,验证的不只是实现分支。 */
@SuppressWarnings("unchecked")
private <T>T tx(T value) {
var proxy=new ProxyFactory(value);proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));return (T)proxy.getProxy();
}
/** 本机内存数据库用后关闭,避免全量回归积累DB_CLOSE_DELAY实例。 */
@AfterEach void closeDatabase(){if(jdbc!=null)jdbc.execute("SHUTDOWN");}
/** 每个请求保留原附加版本和明确量纲。 */
private SaveOperations request(String key,long version,Money money,String distance,String fuel,List<Arrival> arrivals) {
return new SaveOperations(String.valueOf(version),key,new Operations(money,distance,fuel,arrivals),"本机TEST合成记录");
}
}
@@ -0,0 +1,88 @@
package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.*;
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 com.baiyue.lastleg.fleet.ownedbusiness.service.*;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import org.junit.jupiter.api.*;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 上传未知结果和绑定失败恢复回归,不发任何环境HTTP。 */
class OwnedBusinessPhotoTest {
OwnedBusinessPhotoService service;
OwnedBusinessFileGateway files;
OwnedBusinessRecordService records;
OwnedBusinessQueryService queries;
OwnedBusinessRepository repository;
Map<String,OwnedBusinessRows.Command> ledger;
/** 用独立命令集合记录原键,文件服务全部为明确的本机mock。 */
@BeforeEach void setup() {
var codec=OwnedBusinessFixtures.codec();repository=mock(OwnedBusinessRepository.class);queries=mock(OwnedBusinessQueryService.class);
records=mock(OwnedBusinessRecordService.class);files=mock(OwnedBusinessFileGateway.class);ledger=new HashMap<>();
when(queries.requireOwned("DS-OWN-ONE")).thenReturn(OwnedBusinessFixtures.source());
when(repository.command(eq(1L),anyString())).thenAnswer(call->ledger.get(call.getArgument(1)));
doAnswer(call->{OwnedBusinessRows.Command row=call.getArgument(0);ledger.put(row.getIdempotencyKey(),row);return null;}).when(repository).command(any(OwnedBusinessRows.Command.class));
when(repository.finishUpload(any())).thenReturn(1);
service=new OwnedBusinessPhotoService(queries,records,repository,new OwnedBusinessCommandService(repository,codec),
new OwnedBusinessProjection(codec),codec,files);
}
/** 相同原上传请求只执行一次File,回执未绑定不能声称已经保存。 */
@Test void successfulUploadReplaysOriginalFileWithoutUploadingAgain() {
when(files.upload(eq(1L),eq(PhotoSlot.LOADING_SCENE),eq("operator-1"),any())).thenReturn(evidence());
var first=service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,"upload-original",png());
var replay=service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,"upload-original",png());
assertEquals(first,replay);assertEquals("9",first.fileId());assertNull(first.savedAt());
verify(files,times(1)).upload(anyLong(),any(),anyString(),any());verifyNoInteractions(records);
}
/** File响应未知必须保留原键并停写,原请求重试不能再次上传。 */
@Test void unknownUploadStopsInsteadOfRepeatingFileWrite() {
when(files.upload(anyLong(),any(),anyString(),any())).thenThrow(new IllegalStateException("模拟连接中断"));
assertThrows(BizException.class,()->service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.FUEL_START,"upload-unknown",png()));
assertThrows(BizException.class,()->service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.FUEL_START,"upload-unknown",png()));
var recovered=service.command(OwnedBusinessFixtures.actor(),"DS-OWN-ONE","upload-unknown");
assertEquals("UNKNOWN_RESULT",recovered.state());assertFalse(recovered.retryAllowed());assertNull(recovered.result());
verify(files,times(1)).upload(anyLong(),any(),anyString(),any());
}
/** 相同键不能从装车框切换到油耗框,也不能由另一操作者复用。 */
@Test void uploadIdentityIncludesActorSlotAndOriginalBytes() {
when(files.upload(anyLong(),any(),anyString(),any())).thenReturn(evidence());
service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,"upload-identity",png());
assertThrows(BizException.class,()->service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.FUEL_START,"upload-identity",png()));
var other=new Actor("other","其他测试账号",Set.of(LIST,UPLOAD));
assertThrows(ResponseStatusException.class,()->service.command(other,"DS-OWN-ONE","upload-identity"));
verify(files,times(1)).upload(anyLong(),any(),anyString(),any());
}
/** 绑定失败后仍消费原fileId/原绑定键,不触发新的上传。 */
@Test void failedBindingRetainsOriginalFileForExplicitSameRequestRetry() {
when(files.metadata(1,PhotoSlot.LOADING_SCENE,9)).thenReturn(evidence());
when(records.bind(any(),eq("DS-OWN-ONE"),eq(PhotoSlot.LOADING_SCENE),any(),any()))
.thenThrow(new BizException(ResultCode.CONFLICT,"模拟附加版本冲突")).thenReturn(new Detail(null,null,List.of(),List.of()));
var request=new BindPhoto("0","bind-original","9");
assertThrows(BizException.class,()->service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,request));
assertNotNull(service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,request));
verify(files,never()).upload(anyLong(),any(),anyString(),any());
verify(files,times(2)).metadata(1,PhotoSlot.LOADING_SCENE,9);
}
/** 知道文件ID也不能越过当前/历史引用探测内容。 */
@Test void unboundFileIdIsDeniedBeforeCallingFile() {
assertThrows(BizException.class,()->service.content(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.RECEIPT,9,null));
verifyNoInteractions(files);
}
/** 只读用户不能调用上传,File意图也不能落库。 */
@Test void uploadPermissionIsIndependentFromList() {
assertThrows(ResponseStatusException.class,()->service.upload(new Actor("read-only","只读测试账号",Set.of(LIST)),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,"upload-denied",png()));
verifyNoInteractions(files);assertTrue(ledger.isEmpty());
}
/** 本机原始PNG签名用于消费者格式检查,不声称外部真实照片。 */
private MockMultipartFile png(){return new MockMultipartFile("file","TEST.png","image/png",new byte[]{(byte)137,80,78,71,13,10,26,10,0});}
/** 元数据归属在独立gateway测试核验,这里只验证命令生命周期。 */
private OwnedBusinessFileGateway.Evidence evidence(){return new OwnedBusinessFileGateway.Evidence(9,"TEST.png","image/png",9,"2026-10-20T07:30:00");}
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/** 金额按物理托盘计算;原装车和来源客户事实不重新归并。 */
class OwnedBusinessProjectionTest {
/** 同一实托两客户仍是一托,不按3个标准托位或2个来源除价。 */
@Test void sharedPhysicalPalletIsCountedOnceAndUnregisteredMoneyStaysNull() {
var p=new OwnedBusinessProjection(OwnedBusinessFixtures.codec());var source=OwnedBusinessFixtures.source();
var row=p.row(source,null,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor());
assertEquals(1,row.actualPalletCount());assertEquals("0",row.recordVersion());assertEquals("8",row.dispatchRecordVersion());
assertNull(row.operations().money());assertNull(row.unitPrice());assertNull(row.operations().fuelConsumptionLitresPer100Km());
assertEquals(1,row.destinations().size());assertNull(row.departedAt());assertTrue(row.actions().edit().allowed());
assertEquals(8L,source.getRecordVersion());
}
/** 明确0与未登记不同,单价只由服务端权威金额和实托计算。 */
@Test void explicitZeroAndMoneyRoundingRemainAuthoritative() {
var p=new OwnedBusinessProjection(OwnedBusinessFixtures.codec());var record=record();
record.setCurrency("CNY");record.setTotalAmount(BigDecimal.ZERO);
var zero=p.row(OwnedBusinessFixtures.source(),record,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor());
assertEquals("0.00",zero.operations().money().amount());assertEquals("0.00",zero.unitPrice());
record.setTotalAmount(new BigDecimal("10.01"));
assertEquals("10.01",p.row(OwnedBusinessFixtures.source(),record,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor()).unitPrice());
}
/** 无实托不除零,不能用来源数或容量代替。 */
@Test void emptyPhysicalManifestNeverDividesByZero() {
var source=OwnedBusinessFixtures.source();source.setLoadingConfirmationJson("{\"manifestHash\":\"m\",\"pallets\":[],\"operatorId\":\"p\",\"confirmedAt\":\"2026-10-20T07:30:00Z\"}");
var record=record();record.setCurrency("USD");record.setTotalAmount(BigDecimal.ONE);
var row=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()).row(source,record,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor());
assertEquals(0,row.actualPalletCount());assertNull(row.unitPrice());assertFalse(row.sourceIssues().isEmpty());
}
/** null与0在每百公里油耗中同样保持区分,不沿用原型18.6。 */
@Test void explicitFuelRateIsNotDerivedAsTotalFuel() {
var record=record();record.setActualDistanceMiles(BigDecimal.ZERO);record.setFuelConsumptionLitresPer100Km(new BigDecimal("18.600"));
var ops=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()).operations(record);
assertEquals("0",ops.actualDistanceMiles());assertEquals("18.600",ops.fuelConsumptionLitresPer100Km());
}
/** 虚拟记录和空照片保持真实空态。 */
private Record record(){var r=new Record();r.setRecordVersion(1L);r.setArrivalsJson("[]");r.setPhotosJson("[]");return r;}
}
@@ -0,0 +1,73 @@
package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.common.security.AuthenticatedUser;
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.OwnedBusinessRows.*;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import org.mockito.ArgumentCaptor;
import java.time.LocalDateTime;
import java.util.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 同筛选完整统计、旧单不回填和权限隔离的直接回归。 */
class OwnedBusinessQueryTest {
/** 当前页只有一车时统计仍来自全量,日期边界转换为UTC且共用过滤。 */
@Test void summaryDoesNotUseCurrentPageLengthAndDateUsesPlannedDeparture() {
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();
var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchDetailReader.class));
var after=new StageCount();after.setStage("AFTER_LOADING");after.setQuantity(2L);
var completed=new StageCount();completed.setStage("DELIVERED");completed.setQuantity(1L);
when(repo.summary(any())).thenReturn(List.of(after,completed));when(repo.page(any())).thenReturn(List.of(OwnedBusinessFixtures.source()));
when(repo.records(any())).thenReturn(List.of());when(repo.cargo(any())).thenReturn(OwnedBusinessFixtures.cargo());when(repo.batches(any())).thenReturn(List.of());
var result=service.list(OwnedBusinessFixtures.actor(),new Query(1,1,"2026-10-20T00:00:00+08:00","2026-10-21T00:00:00+08:00","ONT8",null,"9"));
assertEquals(1,result.records().size());assertEquals(3,result.total());assertEquals(3,result.summary().dispatchCount());assertEquals(2,result.summary().afterLoadingCount());
var search=ArgumentCaptor.forClass(Search.class);verify(repo).summary(search.capture());verify(repo).page(search.getValue());
assertEquals(LocalDateTime.parse("2026-10-19T16:00:00"),search.getValue().from());
assertEquals(LocalDateTime.parse("2026-10-20T16:00:00"),search.getValue().to());
verify(repo,never()).lock(any());verify(repo,never()).capture(any());verify(repo,never()).update(any(),anyLong());
}
/** 无归属快照不可用当前主档补造,自有页失败不修改原排车。 */
@Test void oldLoadedDispatchWithoutOwnershipIsNotReclassified() {
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();var source=OwnedBusinessFixtures.source();
source.setOwnedVehicleOwnership(null);when(repo.source("DS-OWN-ONE")).thenReturn(source);
var dispatch=mock(DispatchDetailReader.class);var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,dispatch);
assertThrows(BizException.class,()->service.detail(OwnedBusinessFixtures.actor(),"DS-OWN-ONE"));
verifyNoInteractions(dispatch);verify(repo,never()).capture(any());assertEquals(8L,source.getRecordVersion());
}
/** 装车前、非自有归属、失配的原装车hash均不能作为本页可写来源。 */
@Test void stageAndFrozenOwnershipFactsAreBothRequired() {
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();
var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchDetailReader.class));
var source=OwnedBusinessFixtures.source();when(repo.source("DS-OWN-ONE")).thenReturn(source);
source.setStage("LOADING");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE"));
source.setStage("AFTER_LOADING");source.setOwnedVehicleOwnership("DISPATCHED");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE"));
source.setOwnedVehicleOwnership("OWNED");source.setOwnedLoadingCompletionHash("other");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE"));
}
/** 列表权限不自动授予编辑或上传,不暴露主流程写动作。 */
@Test void readOnlyActorHasOnlyReadGates() {
var actor=new Actor("reader","只读账号",Set.of(LIST));var row=new OwnedBusinessProjection(OwnedBusinessFixtures.codec())
.row(OwnedBusinessFixtures.source(),null,OwnedBusinessFixtures.cargo(),List.of(),actor);
assertFalse(row.actions().edit().allowed());assertFalse(row.actions().upload().allowed());
}
/** 客户Token即使携带同名权限也不成为平台运营身份。 */
@Test void customerTokenCannotClaimPlatformThroughPermissions() {
var codec=OwnedBusinessFixtures.codec();var user=new AuthenticatedUser(11L,101L,101L,1L,"客户账号","MERCHANT","WEB",Set.of(LIST,EDIT,UPLOAD));
assertThrows(ResponseStatusException.class,()->codec.actor(user));
var platform=new AuthenticatedUser(12L,0L,0L,2L,"平台账号","PLATFORM","WEB",Set.of(LIST));
assertEquals("平台账号",codec.actor(platform).operatorName());assertEquals("12",codec.actor(platform).operatorId());
}
/** 缺版本和非规范值不能默认为合法0,超长Long也不能截断。 */
@Test void versionsAndFuelRateNumbersAreStrict() {
var codec=OwnedBusinessFixtures.codec();assertEquals(0,codec.version("0"));assertEquals("0.000",codec.decimal("0",9,3));
for(String version:List.of("","-1","00","90071992547409919999"))assertThrows(BizException.class,()->codec.version(version));
for(String value:List.of("-1","18.6001","NaN","18e1","01"))assertThrows(BizException.class,()->codec.decimal(value,9,3));
assertThrows(BizException.class,()->codec.instant("2026-10-20T08:00:00"));
}
}
@@ -12,20 +12,23 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
class FleetTripAuthorizationTest { class FleetTripAuthorizationTest {
@Test void calendarRequiresTripReadPermissionThroughMethodSecurity() { /** 日历按现行排车只读权限放行,预约和旧车次权限不能替代。 */
@Test void calendarRequiresDispatchReadPermissionThroughMethodSecurity() {
try (var context = new AnnotationConfigApplicationContext(Config.class)) { try (var context = new AnnotationConfigApplicationContext(Config.class)) {
var controller = context.getBean(PlatformFleetTripController.class); var controller = context.getBean(PlatformFleetTripController.class);
var calendar = context.getBean(FleetTripCalendarService.class); var calendar = context.getBean(FleetTripCalendarService.class);
var from = java.time.Instant.parse("2026-10-01T00:00:00Z"); var from = java.time.Instant.parse("2026-10-01T00:00:00Z");
var to = from.plusSeconds(86400); var to = from.plusSeconds(86400);
var user = new com.baiyue.common.security.AuthenticatedUser(1L, 0L, 0L, 1L, var user = new com.baiyue.common.security.AuthenticatedUser(1L, 0L, 0L, 1L,
"platform", "PLATFORM", "WEB", java.util.Set.of("platform:fleet:trip:list")); "platform", "PLATFORM", "WEB", java.util.Set.of("platform:overseas-warehouse:dispatch:list"));
for (var permission : List.of("platform:appointment:list", "platform:fleet:trip:list")) {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
user, "unused", List.of(new SimpleGrantedAuthority("platform:appointment:list")))); user, "unused", List.of(new SimpleGrantedAuthority(permission))));
assertThatThrownBy(() -> controller.calendar(user, from, to)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.calendar(user, from, to)).isInstanceOf(AccessDeniedException.class);
}
verifyNoInteractions(calendar); verifyNoInteractions(calendar);
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
user, "unused", List.of(new SimpleGrantedAuthority("platform:fleet:trip:list")))); user, "unused", List.of(new SimpleGrantedAuthority("platform:overseas-warehouse:dispatch:list"))));
controller.calendar(user, from, to); controller.calendar(user, from, to);
verify(calendar).calendar(from, to); verify(calendar).calendar(from, to);
} finally { SecurityContextHolder.clearContext(); } } finally { SecurityContextHolder.clearContext(); }
@@ -11,11 +11,16 @@ import java.util.*;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
class FleetTripCalendarMapperTest { class FleetTripCalendarMapperTest {
/** 同时验证计划和实发的半开范围,取货车次不能混入送仓日历。 */ /** 测试中为每个运输资源建立唯一整车主单,独立旧车次始终不绑定主单。 */
private void bindDispatches(JdbcTemplate jdbc) {
jdbc.execute("INSERT INTO ll_dispatch_order(dispatch_no,trip_id,status) SELECT CONCAT('DS-',t.trip_no),t.id,'ACTIVE' FROM ll_fleet_trip t WHERE t.trip_no<>'TR-UNBOUND' AND NOT EXISTS(SELECT 1 FROM ll_dispatch_order d WHERE d.trip_id=t.id)");
}
/** 验证真实主单关系、计划/实发半开区间及取货排除,不把资源车次号当排车号。 */
@Test void queriesAHalfOpenUtcWindowWithoutStatusFilteringAndCapsCandidateRows() throws Exception { @Test void queriesAHalfOpenUtcWindowWithoutStatusFilteringAndCapsCandidateRows() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var jdbc=new JdbcTemplate(ds); var jdbc=new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_fleet_trip(trip_no VARCHAR(64) PRIMARY KEY,status VARCHAR(32),trip_purpose VARCHAR(32) DEFAULT 'DELIVERY',warehouse_id BIGINT,warehouse_code VARCHAR(64),warehouse_name VARCHAR(100),planned_departure_at TIMESTAMP,expected_driver_end_at TIMESTAMP,departed_at TIMESTAMP,completed_at TIMESTAMP,stops_json VARCHAR(2000))"); jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,trip_no VARCHAR(64) UNIQUE,status VARCHAR(32),trip_purpose VARCHAR(32) DEFAULT 'DELIVERY',warehouse_id BIGINT,warehouse_code VARCHAR(64),warehouse_name VARCHAR(100),planned_departure_at TIMESTAMP,expected_driver_end_at TIMESTAMP,departed_at TIMESTAMP,completed_at TIMESTAMP,stops_json VARCHAR(2000))");
jdbc.execute("CREATE TABLE ll_dispatch_order(dispatch_no VARCHAR(80) PRIMARY KEY,trip_id BIGINT UNIQUE,status VARCHAR(32))");
var from=LocalDateTime.of(2026,11,1,0,0);var to=from.plusDays(1); var from=LocalDateTime.of(2026,11,1,0,0);var to=from.plusDays(1);
String insert="INSERT INTO ll_fleet_trip(trip_no,status,warehouse_id,planned_departure_at,expected_driver_end_at,stops_json) VALUES(?,?,1,?,?, '[]')"; String insert="INSERT INTO ll_fleet_trip(trip_no,status,warehouse_id,planned_departure_at,expected_driver_end_at,stops_json) VALUES(?,?,1,?,?, '[]')";
jdbc.update(insert,"TR-BEFORE","PLANNED",from.minusSeconds(1),to); jdbc.update(insert,"TR-BEFORE","PLANNED",from.minusSeconds(1),to);
@@ -26,11 +31,17 @@ class FleetTripCalendarMapperTest {
jdbc.update(insert,"TR-PICKUP-ACTUAL","IN_TRANSIT",from.minusDays(2),to); jdbc.update(insert,"TR-PICKUP-ACTUAL","IN_TRANSIT",from.minusDays(2),to);
jdbc.update("UPDATE ll_fleet_trip SET trip_purpose='PICKUP' WHERE trip_no LIKE 'TR-PICKUP-%'"); jdbc.update("UPDATE ll_fleet_trip SET trip_purpose='PICKUP' WHERE trip_no LIKE 'TR-PICKUP-%'");
jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no='TR-PICKUP-ACTUAL'",from.plusHours(1)); jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no='TR-PICKUP-ACTUAL'",from.plusHours(1));
bindDispatches(jdbc);
jdbc.update(insert,"TR-UNBOUND","PLANNED",from,to);
jdbc.update(insert,"TR-CANCELLED-DISPATCH","PLANNED",from,to);
bindDispatches(jdbc);
jdbc.update("UPDATE ll_dispatch_order SET status='CANCELLED' WHERE dispatch_no='DS-TR-CANCELLED-DISPATCH'");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(FleetTripMapper.class); var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(FleetTripMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetTripMapper.class); var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetTripMapper.class);
var rows=mapper.calendar(from,to); var rows=mapper.calendar(from,to);
assertThat(rows).extracting(FleetTripEntity::getTripNo).containsExactly("TR-FROM","TR-LAST"); assertThat(rows).extracting(FleetTripEntity::getTripNo).containsExactly("TR-FROM","TR-LAST");
assertThat(rows).extracting(FleetTripCalendarRow::getDispatchNo).containsExactly("DS-TR-FROM","DS-TR-LAST");
assertThat(rows).extracting(FleetTripEntity::getStatus).containsExactly("CANCELLED","COMPLETED"); assertThat(rows).extracting(FleetTripEntity::getStatus).containsExactly("CANCELLED","COMPLETED");
assertThat(rows.get(0).getPlannedDepartureAt()).isEqualTo(from); assertThat(rows.get(0).getPlannedDepartureAt()).isEqualTo(from);
// The actual month is authoritative even when the original plan is outside this window. // The actual month is authoritative even when the original plan is outside this window.
@@ -40,6 +51,7 @@ class FleetTripCalendarMapperTest {
jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no=?",to,"TR-ACTUAL-OUT"); jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no=?",to,"TR-ACTUAL-OUT");
jdbc.update(insert,"TR-ACTUAL-FROM","IN_TRANSIT",from.minusDays(2),to); jdbc.update(insert,"TR-ACTUAL-FROM","IN_TRANSIT",from.minusDays(2),to);
jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no=?",from,"TR-ACTUAL-FROM"); jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no=?",from,"TR-ACTUAL-FROM");
bindDispatches(jdbc);
assertThat(mapper.calendar(from,to)).extracting(FleetTripEntity::getTripNo) assertThat(mapper.calendar(from,to)).extracting(FleetTripEntity::getTripNo)
.containsExactly("TR-ACTUAL-FROM","TR-FROM","TR-ACTUAL-IN","TR-LAST"); .containsExactly("TR-ACTUAL-FROM","TR-FROM","TR-ACTUAL-IN","TR-LAST");
jdbc.update("UPDATE ll_fleet_trip SET planned_departure_at=? WHERE trip_no=?",to.plusDays(20),"TR-ACTUAL-IN"); jdbc.update("UPDATE ll_fleet_trip SET planned_departure_at=? WHERE trip_no=?",to.plusDays(20),"TR-ACTUAL-IN");
@@ -47,6 +59,7 @@ class FleetTripCalendarMapperTest {
.containsExactly("TR-ACTUAL-FROM","TR-FROM","TR-ACTUAL-IN","TR-LAST"); .containsExactly("TR-ACTUAL-FROM","TR-FROM","TR-ACTUAL-IN","TR-LAST");
var many=new ArrayList<Object[]>();for(int i=0;i<2002;i++)many.add(new Object[]{"TR-MANY-"+i,"PLANNED",from.plusHours(1),to}); var many=new ArrayList<Object[]>();for(int i=0;i<2002;i++)many.add(new Object[]{"TR-MANY-"+i,"PLANNED",from.plusHours(1),to});
jdbc.batchUpdate(insert,many); jdbc.batchUpdate(insert,many);
bindDispatches(jdbc);
assertThat(mapper.calendar(from,to)).hasSize(2001); assertThat(mapper.calendar(from,to)).hasSize(2001);
} }
} }
@@ -15,8 +15,8 @@ class FleetTripCalendarServiceTest {
final FleetWarehouseClient warehouses=mock(FleetWarehouseClient.class); final FleetWarehouseClient warehouses=mock(FleetWarehouseClient.class);
final FleetTripCalendarService service=new FleetTripCalendarService(trips,warehouses,new ObjectMapper(),"internal-test"); final FleetTripCalendarService service=new FleetTripCalendarService(trips,warehouses,new ObjectMapper(),"internal-test");
final Instant from=Instant.parse("2026-10-01T00:00:00Z"),to=from.plus(Duration.ofDays(44)); final Instant from=Instant.parse("2026-10-01T00:00:00Z"),to=from.plus(Duration.ofDays(44));
FleetTripEntity trip(String no,Long warehouse) { FleetTripCalendarRow trip(String no,Long warehouse) {
var row=new FleetTripEntity();row.setTripNo(no);row.setWarehouseId(warehouse);row.setStatus("PLANNED"); var row=new FleetTripCalendarRow();row.setTripNo("INTERNAL-"+no);row.setDispatchNo(no);row.setWarehouseId(warehouse);row.setStatus("PLANNED");
row.setPlannedDepartureAt(LocalDateTime.ofInstant(from,ZoneOffset.UTC)); row.setPlannedDepartureAt(LocalDateTime.ofInstant(from,ZoneOffset.UTC));
row.setExpectedDriverEndAt(row.getPlannedDepartureAt().plusDays(2)); row.setExpectedDriverEndAt(row.getPlannedDepartureAt().plusDays(2));
row.setStopsJson("[{\"name\":\"Origin\"},{\"name\":\"ACY2\",\"destinationKey\":\"FBA:ACY2\"}]");return row; row.setStopsJson("[{\"name\":\"Origin\"},{\"name\":\"ACY2\",\"destinationKey\":\"FBA:ACY2\"}]");return row;
@@ -44,13 +44,13 @@ class FleetTripCalendarServiceTest {
metadata(List.of(warehouse(1,"US","America/Los_Angeles"),warehouse(2,"CA","America/Toronto"),warehouse(3,"US","UTC"),warehouse(4,null,"America/New_York")),List.of(5L)); metadata(List.of(warehouse(1,"US","America/Los_Angeles"),warehouse(2,"CA","America/Toronto"),warehouse(3,"US","UTC"),warehouse(4,null,"America/New_York")),List.of(5L));
var result=service.calendar(from,to); var result=service.calendar(from,to);
assertThat(result.records()).singleElement().satisfies(row->{ assertThat(result.records()).singleElement().satisfies(row->{
assertThat(row.sourceType()).isEqualTo("TRIP_PLAN");assertThat(row.tripNo()).isEqualTo("TR-US"); assertThat(row.sourceType()).isEqualTo("DISPATCH");assertThat(row.dispatchNo()).isEqualTo("TR-US");
assertThat(row.status()).isEqualTo("CANCELLED");assertThat(row.originWarehouseStatus()).isEqualTo("INACTIVE"); assertThat(row.status()).isEqualTo("CANCELLED");assertThat(row.originWarehouseStatus()).isEqualTo("INACTIVE");
assertThat(row.plannedDepartureAt()).isEqualTo(from);assertThat(row.departedAt()).isEqualTo(from.plusSeconds(3600)); assertThat(row.plannedDepartureAt()).isEqualTo(from);assertThat(row.departedAt()).isEqualTo(from.plusSeconds(3600));
assertThat(row.expectedDriverEndAt()).isEqualTo(from.plus(Duration.ofDays(2)));assertThat(row.completedAt()).isNull(); assertThat(row.expectedDriverEndAt()).isEqualTo(from.plus(Duration.ofDays(2)));assertThat(row.completedAt()).isNull();
assertThat(row.stops()).hasSize(2);assertThat(row.incompleteReasons()).isEmpty(); assertThat(row.stops()).hasSize(2);assertThat(row.incompleteReasons()).isEmpty();
}); });
assertThat(result.unresolvedRecords()).extracting(FleetTripCalendarService.Entry::tripNo).containsExactly("TR-UTC","TR-COUNTRY","TR-MISSING","TR-NO-ORIGIN"); assertThat(result.unresolvedRecords()).extracting(FleetTripCalendarService.Entry::dispatchNo).containsExactly("TR-UTC","TR-COUNTRY","TR-MISSING","TR-NO-ORIGIN");
assertThat(result.unresolvedRecords().get(0).originTimezone()).isNull(); assertThat(result.unresolvedRecords().get(0).originTimezone()).isNull();
assertThat(result.unresolvedRecords().get(0).incompleteReasons()).containsExactly("ORIGIN_TIMEZONE_UNKNOWN"); assertThat(result.unresolvedRecords().get(0).incompleteReasons()).containsExactly("ORIGIN_TIMEZONE_UNKNOWN");
assertThat(result.unresolvedRecords().get(2).incompleteReasons()).contains("ORIGIN_WAREHOUSE_UNKNOWN"); assertThat(result.unresolvedRecords().get(2).incompleteReasons()).contains("ORIGIN_WAREHOUSE_UNKNOWN");
@@ -78,7 +78,7 @@ class FleetTripCalendarServiceTest {
} }
@Test void endpointUsesExistingReadPermission() throws Exception { @Test void endpointUsesExistingReadPermission() throws Exception {
var method=PlatformFleetTripController.class.getMethod("calendar",com.baiyue.common.security.AuthenticatedUser.class,Instant.class,Instant.class); var method=PlatformFleetTripController.class.getMethod("calendar",com.baiyue.common.security.AuthenticatedUser.class,Instant.class,Instant.class);
assertThat(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:fleet:trip:list')"); assertThat(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:overseas-warehouse:dispatch:list')");
} }
@Test void systemVAndFixedOffsetsAreNotGeographicOriginTimezones() { @Test void systemVAndFixedOffsetsAreNotGeographicOriginTimezones() {
when(trips.calendar(any(),any())).thenReturn(List.of(trip("TR-1",1L))); when(trips.calendar(any(),any())).thenReturn(List.of(trip("TR-1",1L)));
@@ -98,13 +98,13 @@ class FleetTripCalendarServiceTest {
org.springframework.test.util.ReflectionTestUtils.setField(controller,"calendar",calendar); org.springframework.test.util.ReflectionTestUtils.setField(controller,"calendar",calendar);
assertThatThrownBy(()->controller.calendar(null,from,to)).isInstanceOf(ResponseStatusException.class); assertThatThrownBy(()->controller.calendar(null,from,to)).isInstanceOf(ResponseStatusException.class);
for (var user:List.of( for (var user:List.of(
new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"client","MERCHANT","WEB",Set.of("platform:fleet:trip:list")), new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"client","MERCHANT","WEB",Set.of("platform:overseas-warehouse:dispatch:list")),
new com.baiyue.common.security.AuthenticatedUser(1L,9L,9L,1L,"client","PLATFORM","WEB",Set.of("platform:fleet:trip:list")), new com.baiyue.common.security.AuthenticatedUser(1L,9L,9L,1L,"client","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list")),
new com.baiyue.common.security.AuthenticatedUser(1L,null,0L,1L,"client","PLATFORM","WEB",Set.of("platform:fleet:trip:list")), new com.baiyue.common.security.AuthenticatedUser(1L,null,0L,1L,"client","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list")),
new com.baiyue.common.security.AuthenticatedUser(null,0L,0L,1L,"client","PLATFORM","WEB",Set.of("platform:fleet:trip:list")))) new com.baiyue.common.security.AuthenticatedUser(null,0L,0L,1L,"client","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list"))))
assertThatThrownBy(()->controller.calendar(user,from,to)).isInstanceOf(ResponseStatusException.class); assertThatThrownBy(()->controller.calendar(user,from,to)).isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(calendar); verifyNoInteractions(calendar);
var platform=new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"platform","PLATFORM","WEB",Set.of("platform:fleet:trip:list")); var platform=new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"platform","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list"));
controller.calendar(platform,from,to);verify(calendar).calendar(from,to); controller.calendar(platform,from,to);verify(calendar).calendar(from,to);
} }
} }
@@ -56,6 +56,31 @@
"com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity" "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity"
], ],
"reviewedClassDependencies": [ "reviewedClassDependencies": [
{
"source": "com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels",
"target": "com.baiyue.lastleg.dispatch.port.DispatchViews",
"reason": "自有业务组合原排车公开只读详情,不复制履约主模型或引用实体。"
},
{
"source": "com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessProjection",
"target": "com.baiyue.lastleg.dispatch.port.DispatchContracts",
"reason": "从原装车确认与资源公开快照计算真实物理托数,仅消费不可变事实,不写原排车。"
},
{
"source": "com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessQueryService",
"target": "com.baiyue.lastleg.dispatch.port.DispatchContracts",
"reason": "按可信平台身份读取原排车公开快照,核对原装车归属;不访问主流程持久化实体。"
},
{
"source": "com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessQueryService",
"target": "com.baiyue.lastleg.dispatch.port.DispatchTypes",
"reason": "原排车只读详情端口需要可信平台ActorMode,不接受浏览器提供的执行身份。"
},
{
"source": "com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessQueryService",
"target": "com.baiyue.lastleg.dispatch.port.DispatchDetailReader",
"reason": "运营视图通过只读应用端口组合原详情;不直接调用原Service或Mapper,不开放其他消费者。"
},
{ {
"source": "com.baiyue.lastleg.transfer.repository.TransferForecastRepository", "source": "com.baiyue.lastleg.transfer.repository.TransferForecastRepository",
"target": "com.baiyue.lastleg.inboundplan.entity.InboundPlanLine", "target": "com.baiyue.lastleg.inboundplan.entity.InboundPlanLine",