diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql
index b821e6b70..99375cbc7 100644
--- a/deploy/sql/海外区域统一增量-MySQL8.sql
+++ b/deploy/sql/海外区域统一增量-MySQL8.sql
@@ -4882,4 +4882,83 @@ CREATE TABLE ll_fleet_pickup_history (
KEY idx_pickup_history_trip(trip_no,id)
) 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;
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchDetailReadAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchDetailReadAdapter.java
new file mode 100644
index 000000000..9e1afdab7
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchDetailReadAdapter.java
@@ -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);
+ }
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java
index 434f24fc9..b566fec62 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java
@@ -261,7 +261,8 @@ public interface DispatchCandidateMapper {
"AND a.customer_id=#{customerId} " +
"AND a.relabel_no=#{relabelNo} " +
"AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) " +
- "AND pos.hold_packages > 0 " +
+ // 未绑定实托的位置不是可选运输来源;完整申请中的缺失组成仍由提供者返回阻断原因。
+ "AND pos.hold_packages > 0 AND pos.pallet_id > 0 " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " +
"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 " +
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDetailReader.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDetailReader.java
new file mode 100644
index 000000000..4225b40e2
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDetailReader.java
@@ -0,0 +1,7 @@
+package com.baiyue.lastleg.dispatch.port;
+
+/** 原排车详情的只读应用端口,供运营视图组合真实履约事实。 */
+public interface DispatchDetailReader {
+ /** 沿原排车读取及身份守卫,不创建单据、不推进状态。 */
+ DispatchViews.Detail detail(DispatchContracts.Actor actor, String dispatchNo);
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java
index 8c52fba4b..ee7959563 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java
@@ -10,6 +10,7 @@ import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
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.FleetTripExecutionService;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -47,6 +48,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
private final ObjectMapper json;
@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 OwnedBusinessOwnershipCapture ownedBusinessOwnership;
/** 领域编排只访问Repository和提供者应用入口。 */
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());
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());
+ // 同原装车事务保存车辆归属证据;后续档案变化不能改写本趟自有/外配分类。
+ // 原命令重放已在方法入口返回,不重复捕获,也不在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));
}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java
index c2b706f2c..58626665d 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java
@@ -90,7 +90,9 @@ public class DispatchService {
if(facts==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"实托完整来源快照缺失");
reasons.addAll(facts.blockReasons());
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());
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());
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/OwnedBusinessController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/OwnedBusinessController.java
new file mode 100644
index 000000000..ff8898e86
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/OwnedBusinessController.java
@@ -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 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(@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 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 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 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 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 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(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
+ return ApiResponse.success(photos.command(access.actor(user),dispatchNo,idempotencyKey));
+ }
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java
new file mode 100644
index 000000000..a962aa57b
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java
@@ -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.lengthSELECT * FROM ll_fleet_owned_business_record WHERE dispatch_id IN #{id}")
+ List records(@Param("ids") List 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(@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);
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java
new file mode 100644
index 000000000..45588ded0
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java
@@ -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
+ AND d.planned_departure_at >= #{q.from}
+ AND d.planned_departure_at < #{q.to}
+ AND d.stage=#{q.stage}
+ AND d.overseas_warehouse_id=#{q.warehouseId}
+ 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},'%'))))
+ """;
+ /** 只返回当前完整条件下的一车一行。 */
+ @Select("")
+ List page(@Param("q") Search query);
+ /** 同一过滤片段不带分页,不能拿当前页长度冒充统计。 */
+ @Select("")
+ List summary(@Param("q") Search query);
+ /** 先读原主单和可空归属,以明确区别无快照与不存在。 */
+ @Select(SOURCE+" WHERE d.dispatch_no=#{number}")
+ Source find(String number);
+ /** 页内全部货物一次读取,各客户保持原归属。 */
+ @Select("")
+ List cargo(@Param("ids") List ids);
+ /** 只聚合真实FBA分配,目的仓与数量保持原事实,不按客户重复整车。 */
+ @Select("""
+
+ """)
+ List batches(@Param("ids") List ids);
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java
new file mode 100644
index 000000000..25d841394
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java
@@ -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 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 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 destinations,Integer actualPalletCount,Operations operations,String unitPrice,
+ String recordVersion,String dispatchRecordVersion,PhotoSummary photoSummary,Actions actions,List sourceIssues) {}
+ public record Summary(int dispatchCount,int afterLoadingCount,int deliveringCount,int deliveredCount) {}
+ public record ListResult(List 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,List photos) {}
+ public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,
+ Operations operations,List photos) {}
+ public record HistoryPage(List 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 photos,String operatorName) {}
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java
new file mode 100644
index 000000000..20be59f32
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java
@@ -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; }
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/OwnedBusinessOwnershipCapture.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/OwnedBusinessOwnershipCapture.java
new file mode 100644
index 000000000..f69de8cf6
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/OwnedBusinessOwnershipCapture.java
@@ -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);
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/repository/OwnedBusinessRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/repository/OwnedBusinessRepository.java
new file mode 100644
index 000000000..2e9cbe583
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/repository/OwnedBusinessRepository.java
@@ -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 page(Search query){return queries.page(query);}
+ /** 与分页相同过滤的完整统计。 */
+ public List summary(Search query){return queries.summary(query);}
+ /** 读取唯一原排车身份及归属证据。 */
+ public Source source(String number){return queries.find(number);}
+ /** 页内货物批量查询。 */
+ public List cargo(List ids){return ids.isEmpty()?List.of():queries.cargo(ids);}
+ /** 页内实际预约分配批量查询。 */
+ public List batches(List ids){return ids.isEmpty()?List.of():queries.batches(ids);}
+ /** GET只读,无记录保留虚拟0版本。 */
+ public Record record(long id){return records.record(id);}
+ /** 页内附加信息批量读取。 */
+ public List records(List 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(long id,int offset,int size){return records.history(id,offset,size);}
+ /** 下载历史附件时精确定位原版本。 */
+ public History revision(long id,long version){return records.revision(id,version);}
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessCommandService.java
new file mode 100644
index 000000000..7b7174361
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessCommandService.java
@@ -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);
+ }
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessOwnershipService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessOwnershipService.java
new file mode 100644
index 000000000..7a512eb4a
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessOwnershipService.java
@@ -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("原装车车辆归属证据不同");
+ }
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java
new file mode 100644
index 000000000..12cec0302
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java
@@ -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 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);
+ }
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java
new file mode 100644
index 000000000..35223717d
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java
@@ -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 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();
+ 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 cargo,List 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();for(var a:ops.arrivals())arrivals.put(a.destinationKey(),a.actualArrivedAt());
+ var destinations=new LinkedHashMap();
+ 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();}
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java
new file mode 100644
index 000000000..66a102f83
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java
@@ -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();}
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java
new file mode 100644
index 000000000..581205798
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java
@@ -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.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();for(var item:repository.cargo(List.of(source.getId())))destinations.add(projection.destinationKey(item));
+ var seen=new HashSet();var arrivals=new ArrayList();
+ 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);}
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java
new file mode 100644
index 000000000..048c651df
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java
@@ -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 read(String value,Class type) {
+ if(value==null || value.isBlank())throw conflict("缺少可靠业务事实");
+ try{return json.readValue(value,type);}catch(JsonProcessingException error){throw conflict("业务事实读取失败,请核对原记录");}
+ }
+ /** 集合记录与声明的元素类型共同校验。 */
+ public List list(String value,Class 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,"排车单不存在或不属于当前自有业务");}
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java
index 2adcf5885..579500b79 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java
@@ -68,7 +68,9 @@ public class RelabelDispatchSourceRepository {
if (position == null) position = mapper.position(item.getSourceInventoryPositionId());
if (position == null) continue;
positions.put(position.getId(), position);
+ // 无效身份只作为本申请缺失组成保留,不能把其他未绑定位置误连为同一实托。
if (warehouseId.equals(position.getOverseasWarehouseId()) && position.getPalletId() != null
+ && position.getPalletId() > 0
&& !visitedPallets.contains(position.getPalletId())) pending.add(position.getPalletId());
}
}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java
index 28a643181..5052440a0 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java
@@ -149,8 +149,9 @@ public class RelabelDispatchSourceService implements RelabelDispatchSourcePort {
long notified = number(item.getNotifiedPackageCount());
if (!Objects.equals(item.getCustomerId(), app.getCustomerId()) || position == null
|| !Objects.equals(position.getCustomerId(), app.getCustomerId())
- || !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null) {
- reasons.add(app.getRelabelNo() + ":明细当前位置缺失或客户、仓库归属不一致");
+ || !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null
+ || position.getPalletId() <= 0) {
+ reasons.add(app.getRelabelNo() + ":明细当前位置缺失、实托身份无效或客户、仓库归属不一致");
bound = false;
}
if (item.getReturnCartonId() != null || "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) {
diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql
index a0ba74737..95d394795 100644
--- a/lastleg-service/src/main/resources/db/lastleg.sql
+++ b/lastleg-service/src/main/resources/db/lastleg.sql
@@ -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_trip(trip_no,id)
) 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='原排车自有业务附加运营不可变历史';
diff --git a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml
new file mode 100644
index 000000000..dfc46779a
--- /dev/null
+++ b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml
@@ -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
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/DispatchDetailReadAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/DispatchDetailReadAdapterTest.java
new file mode 100644
index 000000000..be7a77388
--- /dev/null
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/DispatchDetailReadAdapterTest.java
@@ -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);
+ }
+}
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java
index 356a5c095..d74c6ed17 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java
@@ -268,12 +268,25 @@ public class DispatchAggregatePersistenceTest {
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();
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));
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");
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(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本机内存事务;不使用返回成功的仓储替身。 */
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 DataSourceTransactionManager transactions;public final ObjectMapper json;public final SqlSessionTemplate session;
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 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,"ownedBusinessOwnership",ownedBusinessOwnership);
org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service);
org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)));
executionReads=new DispatchExecutionRepository(session.getMapper(DispatchExecutionReadMapper.class));
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java
index d4c32ae67..e000a06ac 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java
@@ -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);
}
+ /** 无效实托不成为候选或跨申请扩组锚点;同申请缺失的组成仍明确阻断建单。 */
+ @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均为实际实现。 */
private static final class Merged {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessCommandTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessCommandTest.java
new file mode 100644
index 000000000..b7337f776
--- /dev/null
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessCommandTest.java
@@ -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());
+ }
+}
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFileGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFileGatewayTest.java
new file mode 100644
index 000000000..90b749a63
--- /dev/null
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFileGatewayTest.java
@@ -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"));
+ }
+}
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFixtures.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFixtures.java
new file mode 100644
index 000000000..8f69de744
--- /dev/null
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFixtures.java
@@ -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 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;
+ }
+}
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessOwnershipTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessOwnershipTest.java
new file mode 100644
index 000000000..ba023b4a8
--- /dev/null
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessOwnershipTest.java
@@ -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);
+ }
+}
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java
new file mode 100644
index 000000000..f932ce7f9
--- /dev/null
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java
@@ -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 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 arrivals) {
+ return new SaveOperations(String.valueOf(version),key,new Operations(money,distance,fuel,arrivals),"本机TEST合成记录");
+ }
+}
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPhotoTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPhotoTest.java
new file mode 100644
index 000000000..0e91f16fa
--- /dev/null
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPhotoTest.java
@@ -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 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");}
+}
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessProjectionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessProjectionTest.java
new file mode 100644
index 000000000..ca91c14c5
--- /dev/null
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessProjectionTest.java
@@ -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;}
+}
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java
new file mode 100644
index 000000000..becb67302
--- /dev/null
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java
@@ -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"));
+ }
+}
diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json
index fc1243a00..578a127a9 100644
--- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json
+++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json
@@ -56,6 +56,31 @@
"com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity"
],
"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",
"target": "com.baiyue.lastleg.inboundplan.entity.InboundPlanLine",