Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test
This commit is contained in:
+1
@@ -8,6 +8,7 @@ import java.time.LocalDateTime;
|
||||
public class DispatchAggregate {
|
||||
private Long id;
|
||||
private String dispatchNo;
|
||||
private String taskType;
|
||||
private Long tripId;
|
||||
private String overseasWarehouseId;
|
||||
private String overseasWarehouseCode;
|
||||
|
||||
+7
-3
@@ -14,7 +14,11 @@ public interface DispatchAggregateMapper {
|
||||
<if test='q.dispatchNo != null'> AND d.dispatch_no LIKE CONCAT('%',#{q.dispatchNo},'%')</if>
|
||||
<if test='q.vehiclePlate != null'> AND d.vehicle_plate LIKE CONCAT('%',#{q.vehiclePlate},'%')</if>
|
||||
<if test='q.warehouseId != null'> AND d.overseas_warehouse_id=#{q.warehouseId}</if>
|
||||
<if test='q.customerId != null'> AND EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.id AND i.customer_id=#{q.customerId})</if>
|
||||
<if test='q.customerId != null'> AND (
|
||||
EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.id AND i.customer_id=#{q.customerId})
|
||||
OR EXISTS(SELECT 1 FROM ll_dispatch_truck_task t WHERE t.dispatch_no=d.dispatch_no AND t.customer_id=#{q.customerId})
|
||||
OR EXISTS(SELECT 1 FROM ll_fleet_pickup_task p JOIN ll_fleet_trip f ON f.trip_no=p.trip_no WHERE f.id=d.trip_id AND p.customer_id=#{q.customerId})
|
||||
)</if>
|
||||
<if test='q.fbaAppointmentNo != null'> AND EXISTS(SELECT 1 FROM ll_dispatch_appointment_allocation a JOIN ll_inbound_fba_appointment ap ON a.batch_key=CONCAT('FBA_APPOINTMENT:',ap.id) WHERE a.dispatch_id=d.id AND ap.confirmation_no=#{q.fbaAppointmentNo})</if>
|
||||
""";
|
||||
String REVIEW_DISPATCH = "CASE d.review_state WHEN 'DRAFT' THEN 'NOT_SUBMITTED' WHEN 'DISPATCH_PENDING' THEN 'PENDING' WHEN 'DISPATCH_REJECTED' THEN 'REJECTED' ELSE 'APPROVED' END";
|
||||
@@ -33,12 +37,12 @@ public interface DispatchAggregateMapper {
|
||||
|
||||
/** 首次创建整车所有权及初始版本,不写customer_id。 */
|
||||
@Insert("""
|
||||
INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,overseas_warehouse_id,overseas_warehouse_code,
|
||||
INSERT INTO ll_dispatch_order(id,dispatch_no,task_type,trip_id,overseas_warehouse_id,overseas_warehouse_code,
|
||||
overseas_warehouse_name,status,stage,record_version,cargo_revision,cargo_hash,review_state,review_round,
|
||||
dispatch_review_json,loading_review_json,resources_json,vehicle_plate,route,planned_departure_at,
|
||||
estimated_arrival_at,item_count,pallet_count,package_count,picked_package_count,shipped_package_count,
|
||||
remark,created_by,created_at,updated_at)
|
||||
VALUES(#{r.id},#{r.dispatchNo},#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode},
|
||||
VALUES(#{r.id},#{r.dispatchNo},COALESCE(#{r.taskType},'DELIVERY'),#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode},
|
||||
#{r.overseasWarehouseName},#{r.status},#{r.stage},#{r.recordVersion},#{r.cargoRevision},#{r.cargoHash},
|
||||
#{r.reviewState},#{r.reviewRound},#{r.dispatchReviewJson},#{r.loadingReviewJson},#{r.resourcesJson},
|
||||
#{r.vehiclePlate},#{r.route},#{r.plannedDepartureAt},#{r.estimatedArrivalAt},#{r.itemCount},#{r.palletCount},
|
||||
|
||||
@@ -29,7 +29,7 @@ public final class DispatchViews {
|
||||
public record Page(List<ListRow> records, long total, long page, long size, long pages,
|
||||
Statistics statistics, Instant snapshotAt) { }
|
||||
|
||||
public record ListRow(String dispatchNo, Status status, Stage stage, long recordVersion,
|
||||
public record ListRow(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion,
|
||||
ReviewState reviewState, Reviews reviews, Resources resources,
|
||||
String warehouseId, String warehouseCode, String warehouseName,
|
||||
Instant plannedDepartureAt, Instant departedAt, Counts counts,
|
||||
@@ -56,7 +56,7 @@ public final class DispatchViews {
|
||||
String sourceBusinessType, String sourceBusinessNo) { }
|
||||
|
||||
/** 页头元数据和四个内容对象固定,附加能力不扩展为新页签。 */
|
||||
public record Detail(String dispatchNo, Status status, Stage stage, long recordVersion,
|
||||
public record Detail(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion,
|
||||
ReviewState reviewState, Reviews reviews, Counts counts,
|
||||
boolean pickingReconciled, Map<Action, Gate> actions,
|
||||
VehicleInfo vehicleInfo, DispatchInfo dispatchInfo, Documents documents,
|
||||
|
||||
+8
-2
@@ -658,7 +658,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
d.outboundNo(),d.deliveryNo(),new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(d.type(),d.warehouseCode(),d.warehouseCode(),d.address(),d.postalCode()),d.type(),d.warehouseId(),d.warehouseCode(),d.warehouseName(),d.address(),d.postalCode(),d.contactName(),d.contactPhone()),
|
||||
d.status(),d.packageCount(),d.palletCount())).toList();
|
||||
var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark());
|
||||
return new DispatchViews.Detail(number,Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
|
||||
return new DispatchViews.Detail(number,row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
|
||||
reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()),
|
||||
new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs),
|
||||
documents,orderDetails(cargo));
|
||||
@@ -765,6 +765,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
|
||||
/** 动作快照只反映实际当前资格,写入仍须再次加锁校验。 */
|
||||
Map<Action,Gate> actions(Actor actor,DispatchAggregate row) {
|
||||
// 到仓取货和本土卡车沿运输任务执行,不能进入仓内拣货、扣库存和转派送链。
|
||||
if (row.getTaskType()!=null && !"DELIVERY".equals(row.getTaskType())) {
|
||||
var blocked=new EnumMap<Action,Gate>(Action.class);
|
||||
for(var action:Action.values())blocked.put(action,new Gate(false,"TRANSPORT_TASK","请在排车运输任务中操作"));
|
||||
return Collections.unmodifiableMap(blocked);
|
||||
}
|
||||
var gates=new EnumMap<Action,Gate>(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus());
|
||||
Stage stage=Stage.valueOf(row.getStage()); ReviewState review=ReviewState.valueOf(row.getReviewState());
|
||||
for(var action:Action.values()) {
|
||||
@@ -818,7 +824,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
|
||||
/** 列表不逐行读取明细、附件或派送。 */
|
||||
private DispatchViews.ListRow listRow(Actor actor,DispatchAggregate row) {
|
||||
return new DispatchViews.ListRow(row.getDispatchNo(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
|
||||
return new DispatchViews.ListRow(row.getDispatchNo(),row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
|
||||
reviews(row),read(row.getResourcesJson(),Resources.class),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),counts(row),reconciled(row),row.getArrangementKey()!=null,actions(actor,row));
|
||||
}
|
||||
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.baiyue.lastleg.dispatch.transport;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.fleet.pickup.*;
|
||||
import com.baiyue.lastleg.fleet.trip.*;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/** 两类原单通过现有排车权限接入,禁止客户主体借同名权限调用。 */
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/dispatches/transport-tasks")
|
||||
public class TransportTaskController {
|
||||
private final TransportTaskService service;
|
||||
private final FleetPickupService pickups;
|
||||
private final FleetTripService trips;
|
||||
private final FleetTripCompletionService completion;
|
||||
/** 注入运输业务和现有现场执行能力。 */
|
||||
public TransportTaskController(TransportTaskService service,FleetPickupService pickups,FleetTripService trips,FleetTripCompletionService completion){this.service=service;this.pickups=pickups;this.trips=trips;this.completion=completion;}
|
||||
/** 自动汇入审核后的上门取货需求。 */
|
||||
@GetMapping("/pickup-candidates") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
public ApiResponse<?> pickupCandidates(@AuthenticationPrincipal AuthenticatedUser user,@Valid FleetPickupModels.Query query){operator(user);return ApiResponse.success(pickups.candidates(query));}
|
||||
/** 自动汇入本土卡车待派车需求。 */
|
||||
@GetMapping("/truck-candidates") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
public ApiResponse<?> truckCandidates(@AuthenticationPrincipal AuthenticatedUser user){operator(user);return ApiResponse.success(service.truckCandidates());}
|
||||
/** 创建入仓取货排车。 */
|
||||
@PostMapping("/pickup") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:create')")
|
||||
public ApiResponse<?> pickup(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody FleetPickupModels.Create request){return ApiResponse.success(service.createPickup(operator(user),request));}
|
||||
/** 创建本土卡车排车。 */
|
||||
@PostMapping("/truck") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:create')")
|
||||
public ApiResponse<?> truck(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody TransportTaskModels.TruckCreate request){return ApiResponse.success(service.createTruck(operator(user),request));}
|
||||
/** 当前排车详情的运输执行面板。 */
|
||||
@GetMapping("/{number}") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
public ApiResponse<?> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number){operator(user);return ApiResponse.success(service.detail(number));}
|
||||
/** 分配或改派真实车辆司机。 */
|
||||
@PostMapping("/{number}/assign") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<?> assign(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.Assign request){return ApiResponse.success(service.assign(number,operator(user),request));}
|
||||
/** 取消未发车任务,来源回到待安排。 */
|
||||
@PostMapping("/{number}/cancel") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:cancel')")
|
||||
public ApiResponse<?> cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.Cancel request){return ApiResponse.success(service.cancel(number,operator(user),request));}
|
||||
/** 候选搜索复用统一资源占用真相。 */
|
||||
@PostMapping("/{number}/resource-occupancy") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
public ApiResponse<?> occupancy(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.ResourceQuery query){operator(user);return ApiResponse.success(trips.resourceOccupancy(service.transport(number,false).getTripNo(),query));}
|
||||
/** 实际任务结束后分别释放车辆与司机。 */
|
||||
@PostMapping("/{number}/release") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<?> release(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetCompletionModels.Release request){return ApiResponse.success(completion.release(service.transport(number,false).getTripNo(),operator(user),request));}
|
||||
/** 取货发车仅推进运输。 */
|
||||
@PostMapping("/{number}/pickup/depart") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:depart')")
|
||||
public ApiResponse<?> departPickup(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetPickupModels.Command request){return ApiResponse.success(pickups.depart(service.transport(number,false).getTripNo(),operator(user),request));}
|
||||
/** 全部送仓后结束取货运输。 */
|
||||
@PostMapping("/{number}/pickup/complete") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<?> completePickup(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetPickupModels.Command request){return ApiResponse.success(pickups.complete(service.transport(number,false).getTripNo(),operator(user),request));}
|
||||
/** 凭证归属具体客户原单。 */
|
||||
@PostMapping(value="/{number}/pickup/{customer}/{source}/files",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<?> pickupFile(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable String customer,@PathVariable String source,@RequestPart MultipartFile file){return ApiResponse.success(pickups.upload(service.transport(number,false).getTripNo(),customer,source,operator(user),file));}
|
||||
/** 确认实提箱数和原单凭证。 */
|
||||
@PostMapping("/{number}/pickup/{customer}/{source}/picked") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<?> picked(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable String customer,@PathVariable String source,@Valid @RequestBody FleetPickupModels.Pickup request){return ApiResponse.success(pickups.picked(service.transport(number,false).getTripNo(),customer,source,operator(user),request));}
|
||||
/** 送仓只开放原单收货门禁。 */
|
||||
@PostMapping("/{number}/pickup/{customer}/{source}/arrive") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<?> arrive(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable String customer,@PathVariable String source,@Valid @RequestBody FleetPickupModels.Command request){return ApiResponse.success(pickups.arrive(service.transport(number,false).getTripNo(),customer,source,operator(user),request));}
|
||||
/** 取货凭证同时核对当前排车和原单归属。 */
|
||||
@GetMapping("/{number}/pickup/{customer}/{source}/files/{fileId}") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
public ResponseEntity<byte[]> pickupEvidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable String customer,@PathVariable String source,@PathVariable long fileId){
|
||||
operator(user);var trip=service.transport(number,false);
|
||||
if(pickups.tasks(trip.getTripNo()).stream().noneMatch(task->task.customerId().equals(customer)&&task.transferNo().equals(source)))throw new ResponseStatusException(HttpStatus.NOT_FOUND,"当前排车没有该原单");
|
||||
return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM).body(pickups.evidence(customer,source,fileId));
|
||||
}
|
||||
/** 本土卡车按真实运输发车。 */
|
||||
@PostMapping("/{number}/truck/depart") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:depart')")
|
||||
public ApiResponse<?> departTruck(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody TransportTaskModels.Command request){return ApiResponse.success(service.truckCommand(number,operator(user),"depart",request));}
|
||||
/** 本土卡车提供运输完成凭证后结案。 */
|
||||
@PostMapping("/{number}/truck/complete") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<?> completeTruck(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody TransportTaskModels.Command request){return ApiResponse.success(service.truckCommand(number,operator(user),"complete",request));}
|
||||
/** 本土卡车凭证绑定本次排车资源记录。 */
|
||||
@PostMapping(value="/{number}/truck/files",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<?> truckFile(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@RequestPart MultipartFile file){return ApiResponse.success(service.upload(number,operator(user),file));}
|
||||
/** 完成凭证经排车归属授权后下载。 */
|
||||
@GetMapping("/{number}/truck/files/{fileId}") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
public ResponseEntity<byte[]> evidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable long fileId){operator(user);return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=transport-evidence").body(service.evidence(number,fileId));}
|
||||
/** 认证身份来自网关安全上下文。 */
|
||||
private static String operator(AuthenticatedUser user){
|
||||
if(user==null || user.userId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"请重新登录");
|
||||
if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可以操作排车");return user.userId().toString();
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.baiyue.lastleg.dispatch.transport;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
/** 运输任务与现有排车主单的一对一关系及本土卡车原单关联。 */
|
||||
@Mapper
|
||||
public interface TransportTaskMapper {
|
||||
/** 已成功命令持久化的凭证引用可在刷新后恢复。 */
|
||||
@Select("SELECT evidence_json FROM ll_dispatch_transport_command WHERE dispatch_no=#{number} ORDER BY created_at,request_key")
|
||||
java.util.List<String> evidence(String number);
|
||||
/** 运输命令独立幂等账本,摘要包含动作与版本。 */
|
||||
@Select("SELECT request_hash FROM ll_dispatch_transport_command WHERE dispatch_no=#{number} AND request_key=#{key}")
|
||||
String commandHash(@Param("number") String number,@Param("key") String key);
|
||||
/** 仅在执行成功同事务记账。 */
|
||||
@Insert("INSERT INTO ll_dispatch_transport_command(dispatch_no,request_key,request_hash,evidence_json) VALUES(#{number},#{key},#{hash},#{evidence})")
|
||||
int command(@Param("number") String number,@Param("key") String key,@Param("hash") String hash,@Param("evidence") String evidence);
|
||||
/** 创建键关联原始请求摘要,避免修改来源后重放。 */
|
||||
@Select("SELECT request_hash FROM ll_dispatch_transport_create WHERE trip_no=#{trip}")
|
||||
String createHash(String trip);
|
||||
/** 保存首次本土卡车安排内容。 */
|
||||
@Insert("INSERT INTO ll_dispatch_transport_create(trip_no,request_hash) VALUES(#{trip},#{hash})")
|
||||
int createHashSave(@Param("trip") String trip,@Param("hash") String hash);
|
||||
/** 仅返回已完成原单收货分配且没有承运安排的本土卡车需求。 */
|
||||
@Select("SELECT o.* FROM ll_us_truck_order o WHERE o.status='PENDING_DISPATCH' AND NOT EXISTS(SELECT 1 FROM ll_dispatch_truck_task t WHERE t.customer_id=o.customer_id AND t.order_no=o.order_no AND t.active_order IS NOT NULL) AND NOT EXISTS(SELECT 1 FROM ll_fleet_pool_order_link p WHERE p.customer_id=o.customer_id AND p.business_type='US_TRUCK' AND p.business_order_id=o.id) ORDER BY o.created_at,o.id LIMIT 100")
|
||||
java.util.List<com.baiyue.lastleg.trucking.entity.UsTruckOrder> candidates();
|
||||
/** 原取货创建请求重放时恢复同一排车号。 */
|
||||
@Select("SELECT * FROM ll_dispatch_order WHERE trip_id=#{trip}")
|
||||
DispatchAggregate byTrip(long trip);
|
||||
/** 原单仅允许一个有效承运任务,取消后保留历史。 */
|
||||
@Insert("INSERT INTO ll_dispatch_truck_task(dispatch_no,trip_no,customer_id,order_no,active_order) VALUES(#{dispatchNo},#{tripNo},#{customerId},#{orderNo},CONCAT(#{customerId},':',#{orderNo}))")
|
||||
int insert(TransportTaskModels.TruckLink row);
|
||||
/** 按排车号读取本土卡车关联。 */
|
||||
@Select("SELECT dispatch_no,trip_no,customer_id,order_no FROM ll_dispatch_truck_task WHERE dispatch_no=#{number}")
|
||||
TransportTaskModels.TruckLink truck(String number);
|
||||
/** 取消任务解除有效单唯一约束,历史关联不删除。 */
|
||||
@Update("UPDATE ll_dispatch_truck_task SET active_order=NULL WHERE dispatch_no=#{number}")
|
||||
int cancel(String number);
|
||||
/** 查询排车唯一资源记录。 */
|
||||
@Select("SELECT trip_no FROM ll_fleet_trip WHERE id=#{id}")
|
||||
String tripNo(long id);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.lastleg.dispatch.transport;
|
||||
|
||||
import com.baiyue.lastleg.fleet.trip.FleetTripModels;
|
||||
import com.baiyue.lastleg.fleet.pickup.FleetPickupModels;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** 排车列表中的到仓取货与本土卡车运输命令。 */
|
||||
public final class TransportTaskModels {
|
||||
/** 仅承载契约。 */
|
||||
private TransportTaskModels() { }
|
||||
public record TruckCreate(@NotBlank String customerId,@NotBlank String orderNo,
|
||||
@NotNull @Positive Long warehouseId,@NotNull Instant plannedDepartureAt,
|
||||
@NotNull Instant expectedDriverEndAt,@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
|
||||
public record Command(@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
|
||||
@NotBlank @Size(max=1000) String reason,List<@Positive Long> evidenceFileIds) { }
|
||||
public record Detail(String dispatchNo,String taskType,FleetTripModels.Detail execution,
|
||||
String customerId,String orderNo,List<FleetPickupModels.Task> pickups,
|
||||
List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> completionEvidence) { }
|
||||
public record TruckLink(String dispatchNo,String tripNo,String customerId,String orderNo) { }
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.baiyue.lastleg.dispatch.transport;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchNumberGenerator;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
|
||||
import com.baiyue.lastleg.fleet.trip.TransportTripChanged;
|
||||
import com.baiyue.lastleg.trucking.entity.*;
|
||||
import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
|
||||
/** 现有排车主单与运输事实同步,旧执行入口也不能留下不同步的单据。 */
|
||||
@Service
|
||||
public class TransportTaskProjection {
|
||||
private final DispatchAggregateRepository roots;
|
||||
private final TransportTaskRepository links;
|
||||
private final UsTruckOrderRepository trucks;
|
||||
private final DispatchNumberGenerator numbers;
|
||||
private final ObjectMapper json;
|
||||
/** 注入本地聚合与原单持久化能力,无跨服务数据库访问。 */
|
||||
public TransportTaskProjection(DispatchAggregateRepository roots,TransportTaskRepository links,UsTruckOrderRepository trucks,DispatchNumberGenerator numbers,ObjectMapper json) {
|
||||
this.roots=roots;this.links=links;this.trucks=trucks;this.numbers=numbers;this.json=json;
|
||||
}
|
||||
/** 注册真实排车号;来源行锁和唯一 trip_id 防止重复建单。 */
|
||||
public DispatchAggregate register(FleetTripEntity trip,int items,int pallets,int packages,String operator) {
|
||||
var existing=links.byTrip(trip.getId());if(existing!=null)return existing;
|
||||
var row=new DispatchAggregate();row.setId(IdWorker.getId());row.setDispatchNo(numbers.next());row.setTaskType(trip.getTripPurpose());
|
||||
row.setTripId(trip.getId());row.setOverseasWarehouseId(trip.getWarehouseId().toString());
|
||||
row.setOverseasWarehouseCode(trip.getWarehouseCode());row.setOverseasWarehouseName(trip.getWarehouseName());
|
||||
row.setStatus("ACTIVE");row.setStage("BEFORE_LOADING");row.setRecordVersion(trip.getRecordVersion());row.setCargoRevision(0L);
|
||||
row.setCargoHash(trip.getRequestHash());row.setReviewState("DRAFT");row.setReviewRound(0L);
|
||||
var review=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,0,null,null);
|
||||
row.setDispatchReviewJson(write(review));row.setLoadingReviewJson(write(review));
|
||||
resources(row,trip);row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt());
|
||||
row.setRoute(("PICKUP".equals(trip.getTripPurpose())?"到仓取货":"本土卡车")+" · "+items+"单 · "+trip.getWarehouseCode());row.setItemCount(items);row.setPalletCount(pallets);row.setPackageCount(packages);
|
||||
row.setPickedPackageCount(0);row.setShippedPackageCount(0);row.setRemark(trip.getRemark());row.setCreatedBy(operator);
|
||||
row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));row.setUpdatedAt(row.getCreatedAt());roots.insert(row);
|
||||
roots.history(IdWorker.getId(),row,"TRANSPORT_CREATE",operator,"接入现有排车",write(row),row.getUpdatedAt());return row;
|
||||
}
|
||||
/** 与资源事实同事务更新主单,兼容仍被集成调用的旧执行接口。 */
|
||||
@EventListener
|
||||
public void changed(TransportTripChanged event) {
|
||||
var trip=event.trip();if(!Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(trip.getTripPurpose(),"")))return;
|
||||
var found=links.byTrip(trip.getId());if(found==null)return;
|
||||
var row=roots.lock(found.getDispatchNo());long version=row.getRecordVersion();
|
||||
row.setRecordVersion(trip.getRecordVersion());row.setUpdatedAt(trip.getUpdatedAt());
|
||||
row.setStatus("CANCELLED".equals(trip.getStatus())?"CANCELLED":"ACTIVE");
|
||||
row.setStage(switch(trip.getStatus()){case "IN_TRANSIT"->"DELIVERING";case "COMPLETED"->"DELIVERED";default->"BEFORE_LOADING";});
|
||||
row.setDepartedAt(trip.getDepartedAt());row.setDeliveryCompletedAt(trip.getCompletedAt());
|
||||
row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt());row.setRemark(trip.getRemark());resources(row,trip);
|
||||
if(roots.saveState(row,version)!=1)throw new IllegalStateException("运输排车版本保存失败");
|
||||
roots.history(IdWorker.getId(),row,event.action(),event.operator(),event.reason(),write(row),trip.getUpdatedAt());
|
||||
var link=links.truck(row.getDispatchNo());if(link==null)return;
|
||||
String target=switch(trip.getStatus()){case "CANCELLED"->"PENDING_DISPATCH";case "IN_TRANSIT"->"IN_TRANSIT";case "COMPLETED"->"COMPLETED";default->"DISPATCHED";};
|
||||
transition(link.customerId(),link.orderNo(),target,event.action(),event.operator(),event.reason(),row.getDispatchNo()+":"+trip.getRecordVersion());
|
||||
if("CANCELLED".equals(trip.getStatus()))links.cancel(row.getDispatchNo());
|
||||
}
|
||||
/** 本土卡车只回写真实运输状态并追加原业务不可变历史。 */
|
||||
public void transition(String customer,String number,String target,String action,String operator,String reason,String key) {
|
||||
var order=trucks.lockNo(customer,number);if(order==null)throw new IllegalStateException("关联本土卡车单不存在");
|
||||
String from=order.getStatus();if(target.equals(from))return;
|
||||
boolean allowed=("PENDING_DISPATCH".equals(from)&&"DISPATCHED".equals(target)) || ("DISPATCHED".equals(from)&&Set.of("PENDING_DISPATCH","IN_TRANSIT").contains(target)) || ("IN_TRANSIT".equals(from)&&"COMPLETED".equals(target));
|
||||
if(!allowed)throw new IllegalStateException("本土卡车原单状态与排车执行不一致");
|
||||
order.setStatus(target);order.setVersion(order.getVersion()+1);order.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));trucks.updateOrder(order);
|
||||
var history=new UsTruckOrderStatusHistory();history.setId(IdWorker.getId());history.setOrderId(order.getId());history.setCustomerId(customer);
|
||||
history.setOperationType(action);history.setFromStatus(from);history.setToStatus(target);history.setOperatorId(operator);history.setIdempotencyKey(key);
|
||||
history.setRemark(reason);history.setCreatedAt(order.getUpdatedAt());trucks.insertHistory(history);
|
||||
}
|
||||
/** 主单仅展示真实主档分配,不伪造临时证照或仓库装载事实。 */
|
||||
private void resources(DispatchAggregate row,FleetTripEntity trip) {
|
||||
row.setVehiclePlate(trip.getVehiclePlate());row.setResourcesJson(write(new Resources(
|
||||
trip.getVehicleId()==null?null:new VehicleSnapshot(ResourceMode.MASTER,trip.getVehicleId(),trip.getVehiclePlate(),null,null,new Capacity(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),List.of()),
|
||||
trip.getDriverId()==null?null:new DriverSnapshot(ResourceMode.MASTER,trip.getDriverId(),trip.getDriverName(),trip.getDriverPhone(),null))));
|
||||
}
|
||||
/** 保存明确的业务快照,失败时终止事务。 */
|
||||
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("运输快照序列化失败",e);}}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.baiyue.lastleg.dispatch.transport;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/** 运输关联持久化边界,不在此决定业务状态。 */
|
||||
@Repository
|
||||
public class TransportTaskRepository {
|
||||
private final TransportTaskMapper mapper;
|
||||
/** 注入本域 Mapper。 */
|
||||
public TransportTaskRepository(TransportTaskMapper mapper) { this.mapper=mapper; }
|
||||
/** 已确认完成凭证引用。 */
|
||||
public java.util.List<String> evidence(String number){return mapper.evidence(number);}
|
||||
/** 读取已成功执行的运输命令摘要。 */
|
||||
public String commandHash(String number,String key){return mapper.commandHash(number,key);}
|
||||
/** 成功后记账。 */
|
||||
public void command(String number,String key,String hash,String evidence){mapper.command(number,key,hash,evidence);}
|
||||
/** 读取原创建内容摘要。 */
|
||||
public String createHash(String trip){return mapper.createHash(trip);}
|
||||
/** 保存创建内容摘要。 */
|
||||
public void createHash(String trip,String hash){mapper.createHashSave(trip,hash);}
|
||||
/** 按资源记录恢复排车主单。 */
|
||||
public DispatchAggregate byTrip(long trip) { return mapper.byTrip(trip); }
|
||||
/** 保存原本土卡车单关联。 */
|
||||
public void insert(TransportTaskModels.TruckLink row) { mapper.insert(row); }
|
||||
/** 读取原单关联。 */
|
||||
public TransportTaskModels.TruckLink truck(String number) { return mapper.truck(number); }
|
||||
/** 取消后允许原单重新安排。 */
|
||||
public void cancel(String number) { mapper.cancel(number); }
|
||||
/** 解析主单对应资源业务号。 */
|
||||
public String tripNo(long id) { return mapper.tripNo(id); }
|
||||
/** 最早的一百票待派车原单,安排成功后自动移出候选。 */
|
||||
public java.util.List<com.baiyue.lastleg.trucking.entity.UsTruckOrder> candidates() { return mapper.candidates(); }
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package com.baiyue.lastleg.dispatch.transport;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
|
||||
import com.baiyue.lastleg.fleet.pickup.*;
|
||||
import com.baiyue.lastleg.fleet.trip.*;
|
||||
import com.baiyue.lastleg.fleet.driver.DriverService;
|
||||
import com.baiyue.lastleg.fleet.reporting.TripOperationsEvidenceGateway;
|
||||
import com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries;
|
||||
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
|
||||
import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/** 现有排车入口编排运输任务,不调用仓内拣货、装车审核及扣库存命令。 */
|
||||
@Service
|
||||
public class TransportTaskService {
|
||||
private final DispatchAggregateRepository roots;
|
||||
private final TransportTaskRepository links;
|
||||
private final TransportTaskProjection projection;
|
||||
private final FleetTripRepository tripRepo;
|
||||
private final FleetTripService trips;
|
||||
private final FleetPickupService pickups;
|
||||
private final FleetPickupRepository pickupFacts;
|
||||
private final UsTruckOrderRepository trucks;
|
||||
private final FleetPoolOrderQueries pool;
|
||||
private final DriverService drivers;
|
||||
private final FleetTransportVehicleGuard vehicles;
|
||||
private final TripOperationsEvidenceGateway files;
|
||||
private final ObjectMapper json;
|
||||
/** 复用已有资格、占用、原单及凭证能力。 */
|
||||
public TransportTaskService(DispatchAggregateRepository roots,TransportTaskRepository links,TransportTaskProjection projection,
|
||||
FleetTripRepository tripRepo,FleetTripService trips,FleetPickupService pickups,FleetPickupRepository pickupFacts,
|
||||
UsTruckOrderRepository trucks,FleetPoolOrderQueries pool,DriverService drivers,FleetTransportVehicleGuard vehicles,TripOperationsEvidenceGateway files,ObjectMapper json) {
|
||||
this.roots=roots;this.links=links;this.projection=projection;this.tripRepo=tripRepo;this.trips=trips;this.pickups=pickups;this.pickupFacts=pickupFacts;
|
||||
this.trucks=trucks;this.pool=pool;this.drivers=drivers;this.vehicles=vehicles;this.files=files;this.json=json;
|
||||
}
|
||||
/** 待排本土卡车仅展示未被独立排车或拼车承运占用的原单。 */
|
||||
public List<UsTruckOrder> truckCandidates(){return links.candidates();}
|
||||
/** 同仓取货沿用原任务规则,并注册一个真实排车主单。 */
|
||||
@Transactional
|
||||
public TransportTaskModels.Detail createPickup(String operator,FleetPickupModels.Create request) {
|
||||
var created=pickups.create(operator,request);var trip=tripRepo.lockTrip(created.trip().tripNo());
|
||||
var tasks=pickups.tasks(trip.getTripNo());var row=projection.register(trip,tasks.size(),0,tasks.stream().mapToInt(FleetPickupModels.Task::packageCount).sum(),operator);
|
||||
return detail(row.getDispatchNo());
|
||||
}
|
||||
/** 原单锁和有效关联唯一约束共同阻止同单重复排车。 */
|
||||
@Transactional
|
||||
public TransportTaskModels.Detail createTruck(String operator,TransportTaskModels.TruckCreate request) {
|
||||
String hash=hash(write(request));var prior=tripRepo.findCreated(operator,request.idempotencyKey());
|
||||
if(prior!=null)return replayCreate(prior,hash);
|
||||
var order=trucks.lockNo(request.customerId(),request.orderNo());if(order==null)throw missing("本土卡车单不存在");
|
||||
prior=tripRepo.findCreated(operator,request.idempotencyKey());if(prior!=null)return replayCreate(prior,hash);
|
||||
pool.requireStandalone(order.getCustomerId(),"US_TRUCK",order.getId());
|
||||
if(!"PENDING_DISPATCH".equals(order.getStatus()) || trucks.hasActiveDispatch(order.getCustomerId(),order.getOrderNo()))throw conflict("仅待派车且未安排承运的本土卡车单可以接入排车");
|
||||
if(order.getOriginAddress()==null || order.getOriginAddress().isBlank() || order.getDestinationAddress()==null || order.getDestinationAddress().isBlank())throw conflict("请先补齐原单起终点地址");
|
||||
var created=trips.createLocalTruck(operator,new FleetTripModels.Create(request.warehouseId(),request.plannedDepartureAt(),request.expectedDriverEndAt(),
|
||||
List.of(new FleetTripModels.Stop(order.getOrigin(),order.getOriginAddress(),null),new FleetTripModels.Stop(order.getDestination(),order.getDestinationAddress(),null)),"本土卡车:"+order.getOrderNo(),request.idempotencyKey()));
|
||||
var trip=tripRepo.lockTrip(created.trip().tripNo());var row=projection.register(trip,1,value(order.getPalletCount()),value(order.getPackageCount()),operator);
|
||||
links.insert(new TransportTaskModels.TruckLink(row.getDispatchNo(),trip.getTripNo(),order.getCustomerId(),order.getOrderNo()));links.createHash(trip.getTripNo(),hash);
|
||||
projection.transition(order.getCustomerId(),order.getOrderNo(),"DISPATCHED","TRANSPORT_ASSIGN",operator,"接入排车:"+row.getDispatchNo(),request.idempotencyKey());
|
||||
return detail(row.getDispatchNo());
|
||||
}
|
||||
/** 重放只返回同一原始请求,不受后续原单状态推进影响。 */
|
||||
private TransportTaskModels.Detail replayCreate(FleetTripEntity trip,String hash) {
|
||||
if(!"LOCAL_TRUCK".equals(trip.getTripPurpose()) || !hash.equals(links.createHash(trip.getTripNo())))throw conflict("同一安排请求不能更换来源或时间");
|
||||
var row=links.byTrip(trip.getId());if(row==null)throw conflict("原安排尚未完成,请重试");return detail(row.getDispatchNo());
|
||||
}
|
||||
/** 通过排车号读取运输执行,不要求访问已隐藏的管理页面。 */
|
||||
@Transactional(readOnly=true)
|
||||
public TransportTaskModels.Detail detail(String number) {
|
||||
var trip=transport(number,false);var row=links.byTrip(trip.getId());var link=links.truck(number);
|
||||
return new TransportTaskModels.Detail(number,row.getTaskType(),trips.detail(trip.getTripNo()),link==null?null:link.customerId(),link==null?null:link.orderNo(),
|
||||
"PICKUP".equals(row.getTaskType())?pickups.tasks(trip.getTripNo()):List.of(),
|
||||
evidenceIds(number).stream().map(id->files.metadata(trip.getId(),id)).toList());
|
||||
}
|
||||
/** 仅允许读取已确认属于本排车的完成凭证,文件元数据再次核对资源归属。 */
|
||||
public byte[] evidence(String number,long id){var trip=transport(number,false);if(!evidenceIds(number).contains(id))throw missing("当前排车没有该完成凭证");files.metadata(trip.getId(),id);return files.download(trip.getId(),id);}
|
||||
/** 账本引用去重,不把上传但未确认的文件当作运输完成证据。 */
|
||||
private List<Long> evidenceIds(String number){return links.evidence(number).stream().filter(Objects::nonNull).flatMap(value->{try{return json.readValue(value,new com.fasterxml.jackson.core.type.TypeReference<List<Long>>(){}).stream();}catch(Exception e){throw new IllegalStateException("运输凭证引用读取失败",e);}}).distinct().toList();}
|
||||
/** 所有命令先锁资源父记录,再由既有服务核对公共执行版本。 */
|
||||
public FleetTripEntity transport(String number,boolean lock) {
|
||||
var root=roots.find(number);if(root==null)throw missing("排车单不存在");
|
||||
if(!Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(root.getTaskType(),"")))throw conflict("当前排车单不是运输任务");
|
||||
String tripNo=links.tripNo(root.getTripId());var trip=lock?tripRepo.lockTrip(tripNo):tripRepo.findTrip(tripNo);if(trip==null)throw missing("运输资源记录不存在");return trip;
|
||||
}
|
||||
/** 分配实际资源,容量超限时整次分配回滚。 */
|
||||
@Transactional
|
||||
public TransportTaskModels.Detail assign(String number,String operator,FleetTripModels.Assign request) {
|
||||
var trip=transport(number,true);trips.assign(trip.getTripNo(),operator,request);
|
||||
if("LOCAL_TRUCK".equals(trip.getTripPurpose()))capacity(tripRepo.findTrip(trip.getTripNo()),truck(number));return detail(number);
|
||||
}
|
||||
/** 取消运输并保留原单、排车及资源审计。 */
|
||||
@Transactional
|
||||
public TransportTaskModels.Detail cancel(String number,String operator,FleetTripModels.Cancel request) {
|
||||
var trip=transport(number,true);trips.cancel(trip.getTripNo(),operator,request);return detail(number);
|
||||
}
|
||||
/** 本土卡车发车和完成不制造仓库收发货记录。 */
|
||||
@Transactional
|
||||
public TransportTaskModels.Detail truckCommand(String number,String operator,String action,TransportTaskModels.Command request) {
|
||||
var trip=transport(number,true);if(!"LOCAL_TRUCK".equals(trip.getTripPurpose()))throw conflict("当前不是本土卡车运输任务");
|
||||
String hash=hash(action+":"+write(request));String prior=links.commandHash(number,request.idempotencyKey());
|
||||
if(prior!=null){if(!prior.equals(hash))throw conflict("同一请求标识不能提交不同内容");return detail(number);}
|
||||
if(!Objects.equals(trip.getRecordVersion(),request.recordVersion()))throw conflict("运输任务已变化,请刷新后重试");
|
||||
var order=truck(number);
|
||||
if("depart".equals(action)) {
|
||||
if(!"PLANNED".equals(trip.getStatus()) || !"DISPATCHED".equals(order.getStatus()))throw conflict("仅已派车的计划任务可发车");
|
||||
requireResources(trip);capacity(trip,order);trip.setStatus("IN_TRANSIT");trip.setDepartedAt(now());
|
||||
} else if("complete".equals(action)) {
|
||||
if(!"IN_TRANSIT".equals(trip.getStatus()) || !"IN_TRANSIT".equals(order.getStatus()))throw conflict("请先确认发车");
|
||||
if(request.evidenceFileIds()==null || request.evidenceFileIds().isEmpty())throw conflict("请上传至少一份本次运输完成凭证");
|
||||
for(long id:request.evidenceFileIds())files.metadata(trip.getId(),id);
|
||||
trip.setStatus("COMPLETED");trip.setCompletedAt(now());
|
||||
trip.setCompletionJson(write(new FleetCompletionModels.Completed(request.idempotencyKey(),hash,trip.getCompletedAt().toInstant(ZoneOffset.UTC),Instant.now(),operator,List.of())));
|
||||
} else throw conflict("不支持的运输动作");
|
||||
trips.recordExecution(trip,request.recordVersion(),"LOCAL_TRUCK_"+action.toUpperCase(Locale.ROOT),request.reason(),operator);
|
||||
links.command(number,request.idempotencyKey(),hash,write(request.evidenceFileIds()==null?List.of():request.evidenceFileIds()));return detail(number);
|
||||
}
|
||||
/** 上传只接受当前任务执行期间的真实文件,文件服务强制归属此资源记录。 */
|
||||
@Transactional
|
||||
public com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile upload(String number,String operator,MultipartFile file) {
|
||||
var trip=transport(number,true);if(!"LOCAL_TRUCK".equals(trip.getTripPurpose()) || !Set.of("PLANNED","IN_TRANSIT").contains(trip.getStatus()))throw conflict("当前任务不能上传运输凭证");
|
||||
if(file==null || file.isEmpty() || file.getSize()>10L*1024*1024)throw conflict("请上传不超过10MB的凭证");return files.upload(trip.getId(),operator,file);
|
||||
}
|
||||
/** 原单在执行时锁定,不能通过普通原单手工推进绕过排车。 */
|
||||
private UsTruckOrder truck(String number){var link=links.truck(number);if(link==null)throw missing("本土卡车关联不存在");var order=trucks.lockNo(link.customerId(),link.orderNo());if(order==null)throw missing("本土卡车原单不存在");return order;}
|
||||
/** 发车再次核对占用及证照,不把候选阶段校验当成执行资格。 */
|
||||
private void requireResources(FleetTripEntity trip) {
|
||||
if(trip.getVehicleId()==null || trip.getDriverId()==null || !pickupFacts.ownsResources(trip.getId(),trip.getVehicleId(),trip.getDriverId()))throw conflict("请先分配有效车辆和司机");
|
||||
vehicles.validate(trip);
|
||||
var driver=drivers.requireDispatchable(trip.getDriverId(),trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));if(!"OVERSEAS".equals(driver.details().region()))throw conflict("请选择海外司机");
|
||||
}
|
||||
/** 原单实收托量、申报重量和体积均不得超过已分配车辆能力。 */
|
||||
private void capacity(FleetTripEntity trip,UsTruckOrder order) {
|
||||
if(trip.getMaxPalletSpaces()==null || value(order.getPalletCount())>trip.getMaxPalletSpaces() || trip.getMaxLoadKg()==null || trip.getMaxVolumeCbm()==null
|
||||
|| (order.getTotalWeightKg()!=null && order.getTotalWeightKg().compareTo(trip.getMaxLoadKg())>0)
|
||||
|| (order.getTotalVolumeCbm()!=null && order.getTotalVolumeCbm().compareTo(trip.getMaxVolumeCbm())>0))throw conflict("本土卡车货量超过所选车辆托位、载重或容积");
|
||||
}
|
||||
/** 可空业务数量按零显示,不产生虚构实收。 */
|
||||
private static int value(Integer value){return value==null?0:value;}
|
||||
/** 固定 UTC 记录执行时刻。 */
|
||||
private static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC).withNano(0);}
|
||||
/** 稳定序列化请求摘要。 */
|
||||
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}}
|
||||
/** 不在账本保存原始隐私内容。 */
|
||||
private static String hash(String value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}}
|
||||
/** 返回业务冲突。 */
|
||||
private static ResponseStatusException conflict(String value){return new ResponseStatusException(HttpStatus.CONFLICT,value);}
|
||||
/** 返回不可访问资源。 */
|
||||
private static ResponseStatusException missing(String value){return new ResponseStatusException(HttpStatus.NOT_FOUND,value);}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.lastleg.fleet.business;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface FleetLooseboardDemandMapper {
|
||||
/** 分页读取未匹配且没有独立实物履约的客户散板需求。 */
|
||||
@Select("<script>SELECT o.* FROM ll_local_looseboard_order o WHERE o.status='PENDING_ACCEPTANCE' AND o.lastleg_order_id IS NULL "
|
||||
+ "AND NOT EXISTS (SELECT 1 FROM ll_fleet_external_booking b WHERE b.customer_id=o.customer_id AND b.source_looseboard_order_id=o.id AND b.status!='CANCELLED') "
|
||||
+ "<if test='keyword != null and keyword != ""'>AND (o.order_no LIKE CONCAT('%',#{keyword},'%') OR o.origin LIKE CONCAT('%',#{keyword},'%') OR o.destination LIKE CONCAT('%',#{keyword},'%'))</if> "
|
||||
+ "ORDER BY o.created_at DESC,o.id DESC</script>")
|
||||
Page<LocalLooseboardOrder> pending(Page<LocalLooseboardOrder> page,@Param("keyword") String keyword);
|
||||
/** 原单锁内查询生效匹配,防止跨车次重复接单。 */
|
||||
@Select("SELECT 1 FROM ll_fleet_external_booking WHERE customer_id=#{customer} AND source_looseboard_order_id=#{id} AND status!='CANCELLED' LIMIT 1 FOR UPDATE")
|
||||
Integer active(@Param("customer") String customer,@Param("id") long id);
|
||||
/** 保存预订不可变来源,取消后保留历史。 */
|
||||
@Update("UPDATE ll_fleet_external_booking SET source_looseboard_order_id=#{id} WHERE id=#{booking} AND customer_id=#{customer} AND source_looseboard_order_id IS NULL")
|
||||
int attach(@Param("booking") long booking,@Param("customer") String customer,@Param("id") long id);
|
||||
/** 锁定当前预订对应的同客户原单。 */
|
||||
@Select("SELECT o.* FROM ll_local_looseboard_order o JOIN ll_fleet_external_booking b ON b.source_looseboard_order_id=o.id AND b.customer_id=o.customer_id WHERE b.id=#{booking} AND b.customer_id=#{customer} FOR UPDATE")
|
||||
LocalLooseboardOrder source(@Param("booking") long booking,@Param("customer") String customer);
|
||||
/** 批量读取草稿或取消记录的原单编号。 */
|
||||
@Select("<script>SELECT b.id AS booking_id,o.order_no AS business_order_no FROM ll_fleet_external_booking b JOIN ll_local_looseboard_order o ON o.id=b.source_looseboard_order_id AND o.customer_id=b.customer_id WHERE b.id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
|
||||
List<FleetPoolOrderLink> sourceNumbers(@Param("ids") Collection<Long> ids);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.lastleg.fleet.business;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
|
||||
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public class FleetLooseboardDemandRepository {
|
||||
private final FleetLooseboardDemandMapper demands;
|
||||
private final LocalLooseboardOrderMapper orders;
|
||||
/** 注入需求查询和原单仓储。 */
|
||||
public FleetLooseboardDemandRepository(FleetLooseboardDemandMapper demands,LocalLooseboardOrderMapper orders) { this.demands=demands;this.orders=orders; }
|
||||
/** 分页读取待匹配原单。 */
|
||||
public Page<LocalLooseboardOrder> pending(int page,int size,String keyword) { return demands.pending(Page.of(page,size),keyword); }
|
||||
/** 锁定客户原单。 */
|
||||
public LocalLooseboardOrder lock(String customer,String number) { return orders.lockNo(customer,number); }
|
||||
/** 核对生效匹配。 */
|
||||
public boolean active(String customer,long id) { return demands.active(customer,id)!=null; }
|
||||
/** 关联不可变来源。 */
|
||||
public int attach(long booking,String customer,long id) { return demands.attach(booking,customer,id); }
|
||||
/** 锁定匹配来源。 */
|
||||
public LocalLooseboardOrder source(long booking,String customer) { return demands.source(booking,customer); }
|
||||
/** 批量读取原单编号。 */
|
||||
public List<FleetPoolOrderLink> sourceNumbers(Collection<Long> ids) { return demands.sourceNumbers(ids); }
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.baiyue.lastleg.fleet.business;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import java.util.Objects;
|
||||
|
||||
@Service
|
||||
public class FleetLooseboardDemandService {
|
||||
private final FleetLooseboardDemandRepository repository;
|
||||
/** 注入原单需求仓储。 */
|
||||
public FleetLooseboardDemandService(FleetLooseboardDemandRepository repository) { this.repository=repository; }
|
||||
/** 已授权平台入口读取待匹配原单。 */
|
||||
@Transactional(readOnly=true)
|
||||
public PageResult<Demand> pending(int page,int size,String keyword) {
|
||||
var rows=repository.pending(page,size,keyword);
|
||||
return new PageResult<>(rows.getRecords().stream().map(Demand::from).toList(),rows.getCurrent(),rows.getSize(),rows.getTotal(),rows.getPages());
|
||||
}
|
||||
public record Demand(String orderNo,String customerId,long version,String origin,String destination,String productName,
|
||||
Integer estimatedPalletCount,Integer packageCount,java.math.BigDecimal totalWeightKg,java.math.BigDecimal totalVolumeCbm,
|
||||
String currency,String originContactName,String originContactPhone,String remark) {
|
||||
/** 仅输出匹配所需资料,不泄露原单幂等键与请求哈希。 */
|
||||
static Demand from(LocalLooseboardOrder o) { return new Demand(o.getOrderNo(),o.getCustomerId(),o.getVersion(),o.getOrigin(),o.getDestination(),o.getProductName(),o.getEstimatedPalletCount(),o.getPackageCount(),o.getTotalWeightKg(),o.getTotalVolumeCbm(),o.getCurrency(),o.getOriginContactName(),o.getOriginContactPhone(),o.getRemark()); }
|
||||
}
|
||||
/** 锁定原单并校验客户、版本与唯一匹配,不预留容量。 */
|
||||
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public LocalLooseboardOrder prepare(String customer,String type,String number,Long version) {
|
||||
if(number==null||number.isBlank()) { if(version!=null)throw conflict("来源版本必须与散板原单一起提交"); return null; }
|
||||
if(!"LOCAL_LOOSEBOARD".equals(type))throw conflict("散板原单只能匹配本土散板承运");
|
||||
var order=repository.lock(customer,number);
|
||||
if(order==null||version==null||!Objects.equals(version,order.getVersion()))throw conflict("散板原单不存在或已变化,请刷新待匹配需求");
|
||||
if(!"PENDING_ACCEPTANCE".equals(order.getStatus())||order.getLastlegOrderId()!=null||repository.active(customer,order.getId()))throw conflict("散板原单已有承运或独立履约,请核对原单");
|
||||
return order;
|
||||
}
|
||||
/** 原单锁保持到事务提交,跨车次匹配只允许成功一次。 */
|
||||
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public void attach(long booking,LocalLooseboardOrder order) {
|
||||
if(order!=null&&repository.attach(booking,order.getCustomerId(),order.getId())!=1)throw conflict("原单匹配失败,请刷新重试");
|
||||
}
|
||||
/** 匹配后禁止改写业务类型或替换来源,允许省略已有来源参数。 */
|
||||
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public void validateEdit(long booking,String customer,String type,String number) {
|
||||
var source=repository.source(booking,customer);
|
||||
if(source!=null&&(!"LOCAL_LOOSEBOARD".equals(type)||number!=null&&!number.equals(source.getOrderNo())))throw conflict("已匹配散板原单不能替换,请取消登记后重新匹配");
|
||||
if(source==null&&number!=null&&!number.isBlank())throw conflict("请从待匹配散板需求创建新的登记");
|
||||
}
|
||||
/** 返回业务冲突。 */
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT,message); }
|
||||
}
|
||||
+10
-4
@@ -20,6 +20,7 @@ import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*;
|
||||
/** Same-database bridge. Never creates inventory, publishes fees, or locks a parent trip. */
|
||||
@Service
|
||||
public class FleetPoolOrderBridge {
|
||||
@jakarta.annotation.Resource private FleetLooseboardDemandRepository demands;
|
||||
private final FleetPoolOrderRepository repository;
|
||||
private final FleetPoolOrderNumberGenerator numbers;
|
||||
private final ObjectMapper json;
|
||||
@@ -47,16 +48,20 @@ public class FleetPoolOrderBridge {
|
||||
validate(in);
|
||||
String hash=hash(in); var prior=repository.lockBooking(in.bookingId());
|
||||
if(prior!=null){if(!hash.equals(prior.getCreationRequestHash()))throw conflict("同一承运来源不能创建不同商业订单");return ref(prior);}
|
||||
var source=demands.source(in.bookingId(),in.customerId());
|
||||
if(source!=null&&(!"LOCAL_LOOSEBOARD".equals(in.businessType())||!"PENDING_ACCEPTANCE".equals(source.getStatus())||source.getLastlegOrderId()!=null))throw conflict("散板原单履约状态已变化");
|
||||
var now=LocalDateTime.now();var row=new FleetPoolOrderLink();
|
||||
row.setId(IdWorker.getId());row.setBookingId(in.bookingId());row.setBookingNo(in.bookingNo());
|
||||
row.setCustomerId(in.customerId());row.setBusinessType(in.businessType());row.setBusinessOrderId(IdWorker.getId());
|
||||
row.setBusinessOrderNo(numbers.next("US_TRUCK".equals(in.businessType())?"TRUCK":"LOOSEBOARD",in.destinationLabel(),row.getBusinessOrderId()));
|
||||
row.setCustomerId(in.customerId());row.setBusinessType(in.businessType());row.setBusinessOrderId(source==null?IdWorker.getId():source.getId());
|
||||
row.setBusinessOrderNo(source==null?numbers.next("US_TRUCK".equals(in.businessType())?"TRUCK":"LOOSEBOARD",in.destinationLabel(),row.getBusinessOrderId()):source.getOrderNo());
|
||||
row.setWarehouseId(in.warehouseId());row.setDestinationKey(in.destinationKey());
|
||||
row.setDeliveryMode(in.deliveryMode());row.setDeliveryInstructions(in.deliveryInstructions());
|
||||
row.setFulfillmentStage("WAITING_INBOUND");row.setStageOccurredAt(now);row.setRecordVersion(0L);
|
||||
row.setCreationRequestHash(hash);row.setCreatedBy(operator);row.setCreatedAt(now);row.setUpdatedAt(now);
|
||||
String transport=in.destinationKey().startsWith("FBA:")?"WAREHOUSE_TO_WAREHOUSE":"WAREHOUSE_TO_DOOR";
|
||||
if("LOCAL_LOOSEBOARD".equals(in.businessType())) {
|
||||
if(source!=null) {
|
||||
source.setStatus("WAITING_INBOUND");source.setVersion(source.getVersion()+1);source.setUpdatedAt(now);repository.updateLoose(source);
|
||||
} else if("LOCAL_LOOSEBOARD".equals(in.businessType())) {
|
||||
var order=new LocalLooseboardOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo());
|
||||
order.setTransportType(transport);
|
||||
// 货名与预计托数沿真实询价保存;不从数量推断打盘服务,也不预设取送时间。
|
||||
@@ -79,7 +84,7 @@ public class FleetPoolOrderBridge {
|
||||
order.setRequestHash(hash);order.setCreatedBy(operator);order.setVersion(0L);order.setCreatedAt(now);order.setUpdatedAt(now);
|
||||
repository.insertTruck(order); // No simulated ll_us_truck_order_pallet rows.
|
||||
}
|
||||
repository.insertLink(row);history(row,null,row.getFulfillmentStage(),operator,"确认承运并建立商业委托",now);
|
||||
repository.insertLink(row);history(row,source==null?null:"PENDING_ACCEPTANCE",row.getFulfillmentStage(),operator,source==null?"确认承运并建立商业委托":"原单匹配车次并确认承运",now);
|
||||
return ref(row);
|
||||
}
|
||||
/** 锁定预订并关联唯一真实来货,不创建第二份实物来源。 */
|
||||
@@ -177,6 +182,7 @@ public class FleetPoolOrderBridge {
|
||||
/** 批量读取预订的业务编号。 */
|
||||
public Map<Long,String> businessOrderNumbers(Collection<Long> ids) {
|
||||
if(ids==null||ids.isEmpty())return Map.of();var result=new HashMap<Long,String>();
|
||||
demands.sourceNumbers(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo()));
|
||||
repository.bookings(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo()));return result;
|
||||
}
|
||||
static void validate(ConfirmedBooking in) {
|
||||
|
||||
+3
-1
@@ -38,7 +38,9 @@ public interface FleetPoolOrderMapper extends BaseMapper<FleetPoolOrderLink> {
|
||||
FleetPoolOrderLink lockIncoming(@Param("customerId") String customerId,@Param("no") String no);
|
||||
@Select("SELECT * FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND dispatch_no=#{no} ORDER BY id FOR UPDATE")
|
||||
List<FleetPoolOrderLink> lockDispatch(@Param("customerId") String customerId,@Param("no") String no);
|
||||
@Select("SELECT COUNT(*) FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND business_type=#{type} AND business_order_id=#{id}")
|
||||
/** 原单锁内使用当前读,避免等待匹配提交后仍按旧快照放行独立修改。 */
|
||||
@Select("SELECT COALESCE((SELECT 1 FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND business_type=#{type} AND business_order_id=#{id} LIMIT 1 FOR UPDATE),0) + "
|
||||
+ "COALESCE((SELECT 1 FROM ll_fleet_external_booking WHERE customer_id=#{customerId} AND #{type}='LOCAL_LOOSEBOARD' AND source_looseboard_order_id=#{id} AND status!='CANCELLED' LIMIT 1 FOR UPDATE),0)")
|
||||
int linked(@Param("customerId") String customerId,@Param("type") String type,@Param("id") long id);
|
||||
@Select("SELECT COUNT(*) FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND incoming_no=#{no}")
|
||||
int linkedIncoming(@Param("customerId") String customerId,@Param("no") String no);
|
||||
|
||||
+10
-1
@@ -36,7 +36,16 @@ public final class FleetCapacityOfferModels {
|
||||
@NotBlank @Pattern(regexp="SELF_DELIVERY|MANUAL_PICKUP_TO_WAREHOUSE") String deliveryMode,
|
||||
Instant estimatedArrivalAt,@Min(1) Integer expectedPalletCount,
|
||||
@NotNull @Valid FleetBookingModels.Cargo cargo,@Valid FleetQuoteCandidateModels.QuoteRef quoteRef,
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey,
|
||||
@Size(max=64) String sourceOrderNo,@Min(0) Long sourceOrderVersion) {
|
||||
/** 保留无原单人工登记的构造方式。 */
|
||||
public InquiryWrite(Long tripRecordVersion,Long offerRecordVersion,Long recordVersion,Long customerId,
|
||||
String businessType,String deliveryMode,Instant estimatedArrivalAt,Integer expectedPalletCount,
|
||||
FleetBookingModels.Cargo cargo,FleetQuoteCandidateModels.QuoteRef quoteRef,String idempotencyKey) {
|
||||
this(tripRecordVersion,offerRecordVersion,recordVersion,customerId,businessType,deliveryMode,
|
||||
estimatedArrivalAt,expectedPalletCount,cargo,quoteRef,idempotencyKey,null,null);
|
||||
}
|
||||
}
|
||||
public record InquiryCommand(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long offerRecordVersion,
|
||||
@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
|
||||
@Size(max=1000) String reason) { }
|
||||
|
||||
+11
-1
@@ -25,6 +25,7 @@ import java.util.*;
|
||||
|
||||
@Service
|
||||
public class FleetCapacityOfferService {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService demands;
|
||||
public static final String LIST="platform:fleet:capacity-offer:list", MANAGE="platform:fleet:capacity-offer:manage";
|
||||
private final FleetCapacityOfferMapper offers;
|
||||
private final FleetCapacityOfferCommandMapper commands;
|
||||
@@ -50,6 +51,11 @@ public class FleetCapacityOfferService {
|
||||
this.offers=offers;this.commands=commands;this.trips=trips;this.bookings=bookings;this.bookingService=bookingService;
|
||||
this.reservations=reservations;this.execution=execution;this.customers=customers;this.orders=orders;this.json=json;this.clock=clock;
|
||||
}
|
||||
/** 平台需求池分页读取客户已下的散板原单。 */
|
||||
@Transactional(readOnly=true)
|
||||
public PageResult<com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService.Demand> pendingDemands(FleetCapacityOfferModels.InquiryQuery query) {
|
||||
writePermission("inquiry-create");return demands.pending(query.page(),query.size(),query.keyword());
|
||||
}
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public PageResult<FleetCapacityOfferModels.Offer> page(FleetCapacityOfferModels.Query query){
|
||||
readPermission();
|
||||
@@ -171,17 +177,20 @@ public class FleetCapacityOfferService {
|
||||
touch(row);var result=new FleetCapacityOfferModels.OfferResult(project(row,trip),false);
|
||||
saveCommand(trip,action,number,request.idempotencyKey(),request,result);return result;
|
||||
}
|
||||
/** 同一事务登记意向及原单来源,确认前不占位。 */
|
||||
@Transactional
|
||||
public FleetCapacityOfferModels.InquiryResult createInquiry(String number,FleetCapacityOfferModels.InquiryWrite request){
|
||||
writePermission("inquiry-create");var initial=offer(number,false);var trip=trip(initial.getTripNo(),true);var row=offer(number,true);
|
||||
var replay=replay(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);
|
||||
if(replay!=null)return replayed(replay);
|
||||
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);validateInquiry(trip,request);
|
||||
var source=demands.prepare(request.customerId().toString(),request.businessType(),request.sourceOrderNo(),request.sourceOrderVersion());
|
||||
var created=bookingService.create(trip.getTripNo(),operator(),new FleetBookingModels.Create(trip.getRecordVersion(),request.customerId(),request.cargo(),"POOL:"+hash(Arrays.asList(number,request.idempotencyKey())),request.quoteRef()));
|
||||
if(created.replayed())throw conflict("意向创建记录不完整,请保留原请求联系调度核对");
|
||||
var booking=bookings.lock(trip.getId(),created.booking().bookingNo());booking.setOfferId(row.getId());booking.setSalesOwnerUserId(operator());apply(booking,request);
|
||||
bookings.updateById(booking);var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,result);return result;
|
||||
bookings.updateById(booking);demands.attach(booking.getId(),source);var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,result);return result;
|
||||
}
|
||||
/** 编辑登记资料时保持客户及原单来源不可变。 */
|
||||
@Transactional
|
||||
public FleetCapacityOfferModels.InquiryResult updateInquiry(String number,FleetCapacityOfferModels.InquiryWrite request){
|
||||
writePermission("inquiry-edit");var initial=inquiryRow(number);own(initial);var trip=trip(initial.getTripNo(),true);var row=offers.lock(offers.selectById(initial.getOfferId()).getOfferNo());
|
||||
@@ -189,6 +198,7 @@ public class FleetCapacityOfferService {
|
||||
var replay=replay(trip,"INQUIRY_EDIT",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);if(replay!=null)return replayed(replay);
|
||||
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);validateInquiry(trip,request);
|
||||
if(!Objects.equals(booking.getCustomerId(),request.customerId().toString()))throw conflict("意向客户不能改写,请取消草稿后重新登记");
|
||||
demands.validateEdit(booking.getId(),booking.getCustomerId(),request.businessType(),request.sourceOrderNo());
|
||||
if(request.recordVersion()==null)throw bad("缺少意向版本");
|
||||
bookingService.updatePool(trip.getTripNo(),number,operator(),new FleetBookingModels.Update(trip.getRecordVersion(),request.recordVersion(),request.cargo(),request.quoteRef()));
|
||||
booking=bookings.lock(trip.getId(),number);apply(booking,request);bookings.updateById(booking);
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/** 运输执行仍由车队应用边界核对车辆主档,不向排车暴露车辆持久化。 */
|
||||
@Service
|
||||
public class FleetTransportVehicleGuard {
|
||||
private final VehicleRepository vehicles;
|
||||
/** 复用车辆应用端口及其行锁。 */
|
||||
public FleetTransportVehicleGuard(VehicleRepository vehicles){this.vehicles=vehicles;}
|
||||
/** 发车核对实时车辆资格,并冻结当次有效容量供业务量复核。 */
|
||||
public void validate(FleetTripEntity trip){
|
||||
var vehicle=vehicles.lockById(trip.getVehicleId()).orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"车辆不存在"));
|
||||
if(!"OVERSEAS".equals(vehicle.region()) || !"IN_SERVICE".equals(vehicle.status()))throw new ResponseStatusException(HttpStatus.CONFLICT,"当前车辆不可出车");
|
||||
trip.setMaxPalletSpaces(vehicle.maxPalletSpaces());trip.setMaxLoadKg(vehicle.maxLoadKg());trip.setMaxVolumeCbm(vehicle.maxVolumeCbm());
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -276,7 +276,7 @@ public class FleetTripExecutionService {
|
||||
}
|
||||
/** 出库装载只能用于派送车次,取货必须沿专用运输链执行。 */
|
||||
private static void requireVersionAndPlan(FleetTripEntity trip, long version) {
|
||||
if ("PICKUP".equals(trip.getTripPurpose())) throw conflict("取货车次请使用取货任务操作");
|
||||
if (java.util.Set.of("PICKUP","LOCAL_TRUCK").contains(java.util.Objects.toString(trip.getTripPurpose(),""))) throw conflict("运输排车请使用对应运输任务操作");
|
||||
if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重新确认");
|
||||
if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作");
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import java.util.*;
|
||||
|
||||
@Service
|
||||
public class FleetTripService {
|
||||
@org.springframework.beans.factory.annotation.Autowired(required=false) private org.springframework.context.ApplicationEventPublisher transportEvents;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workChanges;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations;
|
||||
private final FleetTripRepository repository;
|
||||
@@ -101,6 +102,12 @@ public class FleetTripService {
|
||||
return createPlan(operator, request, "PICKUP");
|
||||
}
|
||||
|
||||
/** 本土卡车运输使用同一资源占用与司机资格规则。 */
|
||||
@Transactional
|
||||
public FleetTripModels.Detail createLocalTruck(String operator, FleetTripModels.Create request) {
|
||||
return createPlan(operator, request, "LOCAL_TRUCK");
|
||||
}
|
||||
|
||||
/** 主单创建事务内建立空资源记录,允许未知计划/临时资料,审核时再核齐。 */
|
||||
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public FleetExecutionModels.DispatchShell createDispatchShell(String operator, FleetExecutionModels.DispatchShellRequest request) {
|
||||
@@ -376,7 +383,7 @@ public class FleetTripService {
|
||||
|
||||
/** 两类车次共用建档逻辑,幂等摘要包含用途以防串用。 */
|
||||
private FleetTripModels.Detail createPlan(String operator, FleetTripModels.Create request, String purpose) {
|
||||
String hash = hash(("PICKUP".equals(purpose) ? "PICKUP:" : "") + write(request));
|
||||
String hash = hash(("DELIVERY".equals(purpose) ? "" : purpose+":") + write(request));
|
||||
var existing = repository.findCreated(operator, request.idempotencyKey());
|
||||
if (existing != null) return replay(existing, hash);
|
||||
validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops());
|
||||
@@ -447,7 +454,7 @@ public class FleetTripService {
|
||||
bookingReservations.requireRoute(trip, request.stops());
|
||||
if (trip.getDriverId() != null) drivers.requireDispatchable(trip.getDriverId(), request.expectedDriverEndAt());
|
||||
boolean unchangedRoute = response(trip).stops().equals(request.stops());
|
||||
if ("PICKUP".equals(trip.getTripPurpose()) && !unchangedRoute) throw conflict("取货路线由原单地址和入仓仓库确定,不能修改为出库路线");
|
||||
if (Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(trip.getTripPurpose(),"")) && !unchangedRoute) throw conflict("运输路线由原单确定,不能修改来源地址");
|
||||
boolean unchangedPlan = unchangedRoute && trip.getPlannedDepartureAt().equals(utc(request.plannedDepartureAt()))
|
||||
&& trip.getExpectedDriverEndAt().equals(utc(request.expectedDriverEndAt()));
|
||||
trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt()));
|
||||
@@ -643,6 +650,7 @@ public class FleetTripService {
|
||||
}
|
||||
/** 为当前版本保存操作人、原因和完整车次历史。 */
|
||||
private void record(FleetTripEntity trip, String action, String reason, String operator) {
|
||||
if(transportEvents!=null)transportEvents.publishEvent(new TransportTripChanged(trip,action,reason,operator));
|
||||
workChanges.changed(trip);
|
||||
var event = new FleetTripHistory(); event.setId(IdWorker.getId()); event.setTripId(trip.getId()); event.setRecordVersion(trip.getRecordVersion());
|
||||
event.setAction(action); event.setReason(trim(reason)); event.setOperatorId(operator); event.setOccurredAt(trip.getUpdatedAt());
|
||||
|
||||
+3
@@ -29,6 +29,9 @@ public class PlatformFleetCapacityOfferController {
|
||||
public ApiResponse<FleetCapacityOfferModels.OfferResult> resume(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Command request){return ApiResponse.success(service.command(offerNo,"RESUME",request));}
|
||||
@PostMapping("/{offerNo}/close") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<FleetCapacityOfferModels.OfferResult> close(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Command request){return ApiResponse.success(service.command(offerNo,"CLOSE",request));}
|
||||
/** 读取客户已提交、等待车次匹配的散板原单。 */
|
||||
@GetMapping("/pending-looseboard-demands") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') or (hasAuthority('platform:fleet:capacity-offer:list') and hasAuthority('platform:fleet:capacity-offer:inquiry-create'))")
|
||||
public ApiResponse<PageResult<com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService.Demand>> pendingDemands(@Valid FleetCapacityOfferModels.InquiryQuery query){return ApiResponse.success(service.pendingDemands(query));}
|
||||
@GetMapping("/inquiries") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<PageResult<FleetCapacityOfferModels.Inquiry>> inquiries(@Valid FleetCapacityOfferModels.InquiryQuery query){return ApiResponse.success(service.inquiries(query));}
|
||||
@GetMapping("/inquiries/{bookingNo}") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
|
||||
/** 同步事务内的运输事实;任一投影失败时原执行一起回滚。 */
|
||||
public record TransportTripChanged(FleetTripEntity trip,String action,String reason,String operator) { }
|
||||
+4
-5
@@ -51,7 +51,7 @@ public class LocalLooseboardService {
|
||||
@Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
/** 在同一事务保存客户需求、唯一尾程来源及创建历史,创建键重复时核对请求哈希。 */
|
||||
/** 在同一事务保存客户需求及创建历史,等待需求池匹配后登记真实来货,创建键重复时核对请求哈希。 */
|
||||
@Transactional
|
||||
public LocalLooseboardResponses.Detail create(String customerId, String operatorId,
|
||||
LocalLooseboardRequests.Create request) {
|
||||
@@ -75,8 +75,7 @@ public class LocalLooseboardService {
|
||||
order.setVersion(0L);
|
||||
order.setCreatedAt(now);
|
||||
order.setUpdatedAt(now);
|
||||
LastlegOrder lastlegOrder = createLastlegOrder(order, requestHash, now);
|
||||
order.setLastlegOrderId(lastlegOrder.getId());
|
||||
// 下单只保存池内需求,实际收货时再创建唯一实物尾程单。
|
||||
try {
|
||||
repository.insertOrder(order);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
@@ -86,8 +85,7 @@ public class LocalLooseboardService {
|
||||
}
|
||||
history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId,
|
||||
key, null, request.remark(), now);
|
||||
lastlegHistory(customerId, lastlegOrder.getId(), null, lastlegOrder.getCurrentStatus(),
|
||||
"Local looseboard order submitted", now);
|
||||
|
||||
return detail(customerId, order.getOrderNo());
|
||||
}
|
||||
|
||||
@@ -158,6 +156,7 @@ public class LocalLooseboardService {
|
||||
}
|
||||
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
|
||||
String next = norm(request.status());
|
||||
if (order.getLastlegOrderId() == null && !CANCELLED.equals(next)) throw conflict("请从散板需求池匹配车次,沿实际收货、排车和签收推进");
|
||||
if (!STATUSES.contains(next)) throw badRequest("Unsupported looseboard order status");
|
||||
String from = order.getStatus();
|
||||
if (next.equals(from)) return response(order);
|
||||
|
||||
+3
@@ -8,6 +8,9 @@ import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface UsTruckOrderMapper extends BaseMapper<UsTruckOrder> {
|
||||
/** 原单被有效排车接管后,禁止独立改状态绕过运输事实。 */
|
||||
@Select("SELECT COUNT(*) FROM ll_dispatch_truck_task WHERE customer_id=#{customer} AND order_no=#{number} AND active_order IS NOT NULL")
|
||||
long activeDispatch(@Param("customer") String customer,@Param("number") String number);
|
||||
@Select("SELECT * FROM ll_us_truck_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1")
|
||||
UsTruckOrder findNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo);
|
||||
|
||||
|
||||
+2
@@ -44,6 +44,8 @@ public class UsTruckOrderRepository {
|
||||
public UsTruckOrder findNo(String customerId, String orderNo) { return orders.findNo(customerId, orderNo); }
|
||||
/** 在当前事务中按客户和订单号加行锁。 */
|
||||
public UsTruckOrder lockNo(String customerId, String orderNo) { return orders.lockNo(customerId, orderNo); }
|
||||
/** 有效运输任务持有原单的执行权。 */
|
||||
public boolean hasActiveDispatch(String customer,String number) { return orders.activeDispatch(customer,number)>0; }
|
||||
/** 按客户和创建幂等键读取。 */
|
||||
public UsTruckOrder findKey(String customerId, String key) { return orders.findKey(customerId, key); }
|
||||
/** 保存新订单。 */
|
||||
|
||||
+1
-1
@@ -396,7 +396,7 @@ public class UsTruckOrderService {
|
||||
}
|
||||
|
||||
/** 按客户范围锁单并校验独立订单资格。 */
|
||||
private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = repository.lockNo(customerId, orderNo); if (o == null) throw notFound(); fleetPool.requireStandalone(customerId, "US_TRUCK", o.getId()); return o; }
|
||||
private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = repository.lockNo(customerId, orderNo); if (o == null) throw notFound(); fleetPool.requireStandalone(customerId, "US_TRUCK", o.getId()); if(repository.hasActiveDispatch(customerId,orderNo))throw conflict("该单已接入排车,请在排车列表执行运输操作"); return o; }
|
||||
/** 复核创建请求摘要并返回既有订单。 */
|
||||
private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); }
|
||||
/** 读取订单资料与实际托盘和操作记录。 */
|
||||
|
||||
@@ -1208,6 +1208,7 @@ CREATE TABLE ll_fleet_external_booking (
|
||||
binding_key VARCHAR(128) NULL COMMENT '关联发运请求键',
|
||||
binding_hash CHAR(64) NULL COMMENT '关联发运摘要',
|
||||
offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空',
|
||||
source_looseboard_order_id BIGINT NULL COMMENT '客户原始散板商业单ID,取消后保留来源',
|
||||
sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人',
|
||||
business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK',
|
||||
delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓',
|
||||
@@ -1222,6 +1223,7 @@ CREATE TABLE ll_fleet_external_booking (
|
||||
KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id),
|
||||
CONSTRAINT fk_fleet_booking_offer FOREIGN KEY(offer_id) REFERENCES ll_fleet_capacity_offer(id),
|
||||
CONSTRAINT chk_fleet_booking_expected_pallet CHECK(expected_pallet_count IS NULL OR expected_pallet_count > 0),
|
||||
KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status),
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_fleet_booking_no(booking_no),
|
||||
UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key),
|
||||
@@ -1898,6 +1900,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt (
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_order (
|
||||
id BIGINT NOT NULL COMMENT '整车排车主单内部主键',
|
||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号',
|
||||
task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车',
|
||||
trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID',
|
||||
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID',
|
||||
overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照',
|
||||
@@ -4639,3 +4642,28 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
|
||||
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='原排车自有业务附加运营不可变历史';
|
||||
|
||||
|
||||
-- 运输原单接入现有排车,不产生仓内库存事实。
|
||||
USE baiyue_lastleg;
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_truck_task (
|
||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
||||
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识',
|
||||
order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号',
|
||||
active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留',
|
||||
PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order),
|
||||
KEY idx_transport_order(customer_id,order_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联';
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_create (
|
||||
trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本';
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command (
|
||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
||||
request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要',
|
||||
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
|
||||
PRIMARY KEY(dispatch_no,request_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 现有排车运输任务
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
平台主体(tenantId=0)在现有排车列表内安排到仓取货、本土卡车,使用真实PC排车号。
|
||||
请求经统一Gateway与Bearer认证;JSON响应HTTP200,业务code=200成功,A0400参数错误、A0403权限不足、A0404不存在、A0409版本或状态冲突。
|
||||
list读取、create安排、edit分配/执行/释放、depart发车、cancel取消权限前缀均为platform:overseas-warehouse:dispatch:。
|
||||
创建及执行使用稳定idempotencyKey;重试保留完整原载荷。取消仅限未发车;完成后分别释放司机、车辆。
|
||||
到仓取货逐原单确认实提和送仓,送仓只开放仓库收货门禁;本土卡车同步原单运输状态,完成须本任务凭证。
|
||||
servers:
|
||||
- url: /api/platform/v1/last-leg/dispatches/transport-tasks
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/pickup-candidates:
|
||||
get:
|
||||
summary: 同仓上门取货候选(list,page/size/keyword/warehouseId)
|
||||
responses: &response
|
||||
'200':
|
||||
description: 业务响应信封;data为对应详情、候选或操作结果
|
||||
/truck-candidates:
|
||||
get:
|
||||
summary: 待派车且未被运输或拼车占用的本土卡车候选(list)
|
||||
responses: *response
|
||||
/pickup:
|
||||
post:
|
||||
summary: 同一海外仓多客户取货安排(create)
|
||||
description: sources为customerId/transferNo数组,plannedDepartureAt和expectedDriverEndAt为ISO时刻;idempotencyKey必填,remark可选。返回Detail。
|
||||
responses: *response
|
||||
/truck:
|
||||
post:
|
||||
summary: 本土卡车原单接入排车(create)
|
||||
description: customerId、orderNo、warehouseId、plannedDepartureAt、expectedDriverEndAt、idempotencyKey必填;原单须PENDING_DISPATCH且未被占用。返回Detail。
|
||||
responses: *response
|
||||
/{number}:
|
||||
parameters: &number
|
||||
- { name: number, in: path, required: true, schema: { type: string }, description: 真实PC排车业务号 }
|
||||
get:
|
||||
summary: 排车运输详情(list)
|
||||
description: data包含dispatchNo、taskType(PICKUP/LOCAL_TRUCK)、execution(现有资源与执行详情)、customerId、orderNo、pickups、completionEvidence(已确认凭证)。
|
||||
responses: *response
|
||||
/{number}/{action}:
|
||||
parameters:
|
||||
- { name: number, in: path, required: true, schema: { type: string } }
|
||||
- { name: action, in: path, required: true, schema: { type: string, enum: [assign, cancel, resource-occupancy, release] } }
|
||||
post:
|
||||
summary: 分配、取消、资源可用性查询与实际任务释放
|
||||
description: >-
|
||||
assign(edit):recordVersion、vehicleId、driverId、reason;cancel(cancel):recordVersion、reason。
|
||||
resource-occupancy(list):kind=VEHICLE/DRIVER、resourceIds数组。
|
||||
release(edit):recordVersion、resourceKind、actualEndAt、endLocation、vehicleStatus(车辆必填)、idempotencyKey、reason。
|
||||
responses: *response
|
||||
/{number}/{kind}/{action}:
|
||||
parameters:
|
||||
- { name: number, in: path, required: true, schema: { type: string } }
|
||||
- { name: kind, in: path, required: true, schema: { type: string, enum: [pickup, truck] } }
|
||||
- { name: action, in: path, required: true, schema: { type: string, enum: [depart, complete] } }
|
||||
post:
|
||||
summary: 运输发车(depart)或完成(edit)
|
||||
description: recordVersion、idempotencyKey、reason必填。本土卡车complete还须至少一份evidenceFileIds,文件归属本任务。取货complete要求所有原单已送仓。
|
||||
responses: *response
|
||||
/{number}/pickup/{customer}/{source}/{action}:
|
||||
parameters:
|
||||
- { name: number, in: path, required: true, schema: { type: string } }
|
||||
- { name: customer, in: path, required: true, schema: { type: string } }
|
||||
- { name: source, in: path, required: true, schema: { type: string }, description: 仓储转运单号 }
|
||||
- { name: action, in: path, required: true, schema: { type: string, enum: [picked, arrive, files] } }
|
||||
post:
|
||||
summary: 按原单上传凭证、确认实提或送仓(edit)
|
||||
description: files使用multipart字段file;picked使用recordVersion、idempotencyKey、actualPackages、evidenceFileIds、remark;arrive使用recordVersion、idempotencyKey、reason。凭证不能跨客户原单使用。
|
||||
responses: *response
|
||||
/{number}/truck/files:
|
||||
parameters: *number
|
||||
post:
|
||||
summary: 本土卡车凭证上传(edit)
|
||||
description: multipart字段file,非空且不超过10MB,只能在计划或运输中上传。上传不等于完成,须通过complete命令确认文件引用。
|
||||
responses: *response
|
||||
/{number}/truck/files/{fileId}:
|
||||
parameters:
|
||||
- { name: number, in: path, required: true, schema: { type: string } }
|
||||
- { name: fileId, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||
get:
|
||||
summary: 已确认本土卡车完成凭证鉴权下载(list)
|
||||
responses:
|
||||
'200': { description: application/octet-stream文件;失败返回业务响应信封 }
|
||||
/{number}/pickup/{customer}/{source}/files/{fileId}:
|
||||
parameters:
|
||||
- { name: number, in: path, required: true, schema: { type: string } }
|
||||
- { name: customer, in: path, required: true, schema: { type: string } }
|
||||
- { name: source, in: path, required: true, schema: { type: string } }
|
||||
- { name: fileId, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||
get:
|
||||
summary: 当前排车原单已确认取货凭证鉴权下载(list)
|
||||
responses:
|
||||
'200': { description: application/octet-stream文件;当前排车及原单归属不符时拒绝 }
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
|
||||
@@ -373,6 +373,36 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OfferCommand'
|
||||
/api/platform/v1/last-leg/fleet/capacity-offers/pending-looseboard-demands:
|
||||
get:
|
||||
summary: 分页读取客户已提交的待匹配散板原单
|
||||
description: 权限:platform:fleet:capacity-offer:manage 或 (platform:fleet:capacity-offer:list 且 platform:fleet:capacity-offer:inquiry-create)。仅返回待接单、没有独立尾程来源且没有生效匹配的原单;取消未确认登记后恢复候选。下单及登记不占位,确认承运才占位。旧独立履约单不回填来源。
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
|
||||
- { name: keyword, in: query, schema: { type: string, maxLength: 128 } }
|
||||
responses:
|
||||
'200':
|
||||
description: 统一业务响应
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: string }
|
||||
message: { type: string }
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
records:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/LooseboardDemand' }
|
||||
page: { type: integer }
|
||||
size: { type: integer }
|
||||
total: { type: integer }
|
||||
pages: { type: integer }
|
||||
'401': { description: 未登录 }
|
||||
'403': { description: 无需求匹配权限 }
|
||||
/api/platform/v1/last-leg/fleet/capacity-offers/inquiries:
|
||||
get:
|
||||
summary: 分页查询本人或获准的全部意向
|
||||
@@ -1310,6 +1340,24 @@ components:
|
||||
minimum: 0
|
||||
- type: string
|
||||
pattern: ^[0-9]+$
|
||||
LooseboardDemand:
|
||||
type: object
|
||||
description: 原单待匹配投影;不含创建幂等键、请求哈希和操作人技术身份。
|
||||
properties:
|
||||
orderNo: { type: string }
|
||||
customerId: { type: string }
|
||||
version: { type: integer, format: int64 }
|
||||
origin: { type: string }
|
||||
destination: { type: string }
|
||||
productName: { type: string, nullable: true }
|
||||
estimatedPalletCount: { type: integer, nullable: true }
|
||||
packageCount: { type: integer, nullable: true }
|
||||
totalWeightKg: { type: number, nullable: true }
|
||||
totalVolumeCbm: { type: number, nullable: true }
|
||||
currency: { type: string }
|
||||
originContactName: { type: string, nullable: true }
|
||||
originContactPhone: { type: string, nullable: true }
|
||||
remark: { type: string, nullable: true }
|
||||
InquiryWrite:
|
||||
type: object
|
||||
required:
|
||||
@@ -1321,6 +1369,18 @@ components:
|
||||
- cargo
|
||||
- idempotencyKey
|
||||
properties:
|
||||
sourceOrderNo:
|
||||
type: string
|
||||
maxLength: 64
|
||||
nullable: true
|
||||
description: 从待匹配散板选择原商业单;仅LOCAL_LOOSEBOARD支持。确认承运复用原单,不另开商业单。
|
||||
sourceOrderVersion:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
nullable: true
|
||||
description: 创建匹配时与原单号一起提交;已匹配登记不可替换来源。
|
||||
|
||||
tripRecordVersion:
|
||||
oneOf:
|
||||
- type: integer
|
||||
|
||||
+19
@@ -442,6 +442,23 @@ public class DispatchAggregatePersistenceTest {
|
||||
assertThat(f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())).counts().packageCount()).isEqualTo(10);
|
||||
}
|
||||
|
||||
/** 到仓取货、本土卡车不制造货物明细,但仍须按原客户出现在现有整车排车列表。 */
|
||||
@Test void customerFilterFindsPickupAndLocalTruckByTheirRealSourceLinks() throws Exception {
|
||||
var f=new Fixture();
|
||||
var pickup=f.tx.execute(s->f.service.create(f.actor,f.create("transport-pickup-list",List.of())));
|
||||
var truck=f.tx.execute(s->f.service.create(f.actor,f.create("transport-truck-list",List.of())));
|
||||
String pickupTrip=f.db.queryForObject("SELECT trip_no FROM ll_fleet_trip WHERE id=(SELECT trip_id FROM ll_dispatch_order WHERE dispatch_no=?)",String.class,pickup.dispatchNo());
|
||||
String truckTrip=f.db.queryForObject("SELECT trip_no FROM ll_fleet_trip WHERE id=(SELECT trip_id FROM ll_dispatch_order WHERE dispatch_no=?)",String.class,truck.dispatchNo());
|
||||
f.db.update("UPDATE ll_dispatch_order SET task_type='PICKUP' WHERE dispatch_no=?",pickup.dispatchNo());
|
||||
f.db.update("UPDATE ll_dispatch_order SET task_type='LOCAL_TRUCK' WHERE dispatch_no=?",truck.dispatchNo());
|
||||
f.db.update("INSERT INTO ll_fleet_pickup_task(customer_id,trip_no) VALUES('PICKUP-CUSTOMER',?)",pickupTrip);
|
||||
f.db.update("INSERT INTO ll_dispatch_truck_task(dispatch_no,customer_id) VALUES(?,'TRUCK-CUSTOMER')",truck.dispatchNo());
|
||||
var pickupPage=f.tx.execute(s->f.service.page(f.actor,new DispatchViews.Query(1,20,null,null,null,"PICKUP-CUSTOMER",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,null,null)));
|
||||
var truckPage=f.tx.execute(s->f.service.page(f.actor,new DispatchViews.Query(1,20,null,null,null,"TRUCK-CUSTOMER",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,null,null)));
|
||||
assertThat(pickupPage.total()).isOne();assertThat(pickupPage.records().get(0).dispatchNo()).isEqualTo(pickup.dispatchNo());assertThat(pickupPage.records().get(0).taskType()).isEqualTo("PICKUP");
|
||||
assertThat(truckPage.total()).isOne();assertThat(truckPage.records().get(0).dispatchNo()).isEqualTo(truck.dispatchNo());assertThat(truckPage.records().get(0).taskType()).isEqualTo("LOCAL_TRUCK");
|
||||
}
|
||||
|
||||
/** 第N项已被另一车占用时,早先本车/来源/资源/账本全部回滚。 */
|
||||
@Test void laterCargoConflictRollsBackTruckResourcesEarlierLocksAndItems() throws Exception {
|
||||
var f=new Fixture(); var a=f.source(1,"101",501,"ONT8",3); var b=f.source(2,"202",502,"LAX9",5);
|
||||
@@ -492,6 +509,8 @@ public class DispatchAggregatePersistenceTest {
|
||||
db.execute("ALTER TABLE ll_fleet_trip_dispatch ADD UNIQUE(trip_id)");
|
||||
db.execute("ALTER TABLE ll_dispatch_physical_pallet_lock ADD UNIQUE(active_warehouse_pallet_id)");
|
||||
db.execute("ALTER TABLE ll_dispatch_pallet_lock ADD UNIQUE(customer_id,active_key)");
|
||||
db.execute("CREATE TABLE ll_dispatch_truck_task(dispatch_no VARCHAR(64),customer_id VARCHAR(128))");
|
||||
db.execute("CREATE TABLE ll_fleet_pickup_task(customer_id VARCHAR(128),trip_no VARCHAR(64))");
|
||||
db.execute("CREATE TABLE ll_dispatch_command(id BIGINT PRIMARY KEY,dispatch_id BIGINT,dispatch_no VARCHAR(64),action VARCHAR(40),operator_id VARCHAR(128),idempotency_key VARCHAR(255) UNIQUE,record_version BIGINT,command_hash CHAR(64),result_json CLOB,created_at TIMESTAMP(3))");
|
||||
db.execute("CREATE TABLE ll_dispatch_history(id BIGINT PRIMARY KEY,dispatch_id BIGINT,record_version BIGINT,action VARCHAR(40),operator_id VARCHAR(128),reason VARCHAR(1000),snapshot_json CLOB,occurred_at TIMESTAMP(3))");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(64),dispatch_item_id BIGINT,inventory_position_id BIGINT,packages BIGINT,product_quantity BIGINT)");
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.lastleg.dispatch.transport;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchNumberGenerator;
|
||||
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
|
||||
import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
|
||||
import com.baiyue.lastleg.fleet.trip.TransportTripChanged;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDateTime;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class TransportTaskProjectionTest {
|
||||
/** 旧取消入口产生的运输事实也必须撤销原单安排和资源投影。 */
|
||||
@Test void legacyCancellationSynchronizesDispatchAndUnlocksSource(){
|
||||
var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class);var trucks=mock(UsTruckOrderRepository.class);
|
||||
var projection=new TransportTaskProjection(roots,links,trucks,mock(DispatchNumberGenerator.class),new ObjectMapper().findAndRegisterModules());
|
||||
var root=new DispatchAggregate();root.setId(2L);root.setDispatchNo("PC1");root.setRecordVersion(3L);root.setTaskType("LOCAL_TRUCK");
|
||||
when(links.byTrip(1L)).thenReturn(root);when(roots.lock("PC1")).thenReturn(root);when(roots.saveState(root,3L)).thenReturn(1);
|
||||
when(links.truck("PC1")).thenReturn(new TransportTaskModels.TruckLink("PC1","TR1","C1","U1"));
|
||||
var order=new UsTruckOrder();order.setId(9L);order.setStatus("DISPATCHED");order.setVersion(5L);when(trucks.lockNo("C1","U1")).thenReturn(order);
|
||||
var trip=new FleetTripEntity();trip.setId(1L);trip.setTripPurpose("LOCAL_TRUCK");trip.setStatus("CANCELLED");trip.setRecordVersion(4L);trip.setUpdatedAt(LocalDateTime.now());
|
||||
projection.changed(new TransportTripChanged(trip,"CANCEL","取消运输","OP"));
|
||||
assertThat(root.getStatus()).isEqualTo("CANCELLED");assertThat(root.getRecordVersion()).isEqualTo(4L);assertThat(order.getStatus()).isEqualTo("PENDING_DISPATCH");assertThat(order.getVersion()).isEqualTo(6L);
|
||||
verify(links).cancel("PC1");verify(trucks).insertHistory(argThat(row->"DISPATCHED".equals(row.getFromStatus())&&"PENDING_DISPATCH".equals(row.getToStatus())&&"C1".equals(row.getCustomerId())));
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package com.baiyue.lastleg.dispatch.transport;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
|
||||
import com.baiyue.lastleg.fleet.pickup.*;
|
||||
import com.baiyue.lastleg.fleet.trip.*;
|
||||
import com.baiyue.lastleg.fleet.driver.DriverService;
|
||||
import com.baiyue.lastleg.fleet.reporting.TripOperationsEvidenceGateway;
|
||||
import com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries;
|
||||
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
|
||||
import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
/** 验证原单承运执行权、凭证隔离和成功请求重放,不能仅靠页面按钮门禁。 */
|
||||
class TransportTaskServiceTest {
|
||||
DispatchAggregateRepository roots;TransportTaskRepository links;FleetTripRepository tripRepo;FleetTripService trips;
|
||||
UsTruckOrderRepository trucks;TripOperationsEvidenceGateway files;TransportTaskService service;FleetTripEntity trip;UsTruckOrder order;
|
||||
final Map<String,String> commands=new HashMap<>();
|
||||
@BeforeEach void setup(){
|
||||
roots=mock(DispatchAggregateRepository.class);links=mock(TransportTaskRepository.class);tripRepo=mock(FleetTripRepository.class);trips=mock(FleetTripService.class);
|
||||
trucks=mock(UsTruckOrderRepository.class);files=mock(TripOperationsEvidenceGateway.class);
|
||||
service=new TransportTaskService(roots,links,mock(TransportTaskProjection.class),tripRepo,trips,mock(FleetPickupService.class),mock(FleetPickupRepository.class),trucks,mock(FleetPoolOrderQueries.class),mock(DriverService.class),mock(FleetTransportVehicleGuard.class),files,new ObjectMapper().findAndRegisterModules());
|
||||
var root=new DispatchAggregate();root.setDispatchNo("PC1");root.setTaskType("LOCAL_TRUCK");root.setTripId(1L);
|
||||
when(roots.find("PC1")).thenReturn(root);when(links.byTrip(1L)).thenReturn(root);when(links.tripNo(1L)).thenReturn("TR1");
|
||||
when(links.truck("PC1")).thenReturn(new TransportTaskModels.TruckLink("PC1","TR1","C1","U1"));when(links.evidence("PC1")).thenReturn(List.of());
|
||||
trip=new FleetTripEntity();trip.setId(1L);trip.setTripNo("TR1");trip.setTripPurpose("LOCAL_TRUCK");trip.setStatus("IN_TRANSIT");trip.setRecordVersion(3L);
|
||||
when(tripRepo.lockTrip("TR1")).thenReturn(trip);when(tripRepo.findTrip("TR1")).thenReturn(trip);
|
||||
order=new UsTruckOrder();order.setStatus("IN_TRANSIT");when(trucks.lockNo("C1","U1")).thenReturn(order);
|
||||
when(links.commandHash(eq("PC1"),anyString())).thenAnswer(call->commands.get(call.getArgument(1)));
|
||||
doAnswer(call->{commands.put(call.getArgument(1),call.getArgument(2));return null;}).when(links).command(eq("PC1"),anyString(),anyString(),anyString());
|
||||
doAnswer(call->{trip.setRecordVersion(trip.getRecordVersion()+1);return null;}).when(trips).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());
|
||||
}
|
||||
/** 无凭证或跨任务文件均不能结束运输,也不能写成功账本。 */
|
||||
@Test void completionRequiresOwnedEvidence(){
|
||||
assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",command("key-missing",List.of()))).hasMessageContaining("凭证");
|
||||
when(files.metadata(1L,99L)).thenThrow(new IllegalArgumentException("跨任务凭证"));
|
||||
assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",command("key-foreign",List.of(99L)))).hasMessageContaining("跨任务");
|
||||
verify(trips,never()).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());verify(links,never()).command(anyString(),anyString(),anyString(),anyString());
|
||||
}
|
||||
/** 同请求在版本变化后仍恢复原成功结果,不重复推进原单。 */
|
||||
@Test void replayUsesOriginalSuccessfulCommandBeforeCurrentVersion(){
|
||||
var body=command("complete-key",List.of(77L));service.truckCommand("PC1","OP","complete",body);service.truckCommand("PC1","OP","complete",body);
|
||||
assertThat(trip.getStatus()).isEqualTo("COMPLETED");verify(trips,times(1)).recordExecution(any(),eq(3L),eq("LOCAL_TRUCK_COMPLETE"),anyString(),eq("OP"));
|
||||
assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",command("complete-key",List.of(88L)))).hasMessageContaining("不同内容");
|
||||
}
|
||||
/** 旧版本和未发车原单均不能利用完成接口跳过真实执行。 */
|
||||
@Test void staleVersionAndWrongOriginalStatusCannotComplete(){
|
||||
assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",new TransportTaskModels.Command(2L,"stale-key","结束运输",List.of(77L)))).hasMessageContaining("刷新");
|
||||
order.setStatus("DISPATCHED");assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",command("wrong-state",List.of(77L)))).hasMessageContaining("先确认发车");verifyNoInteractions(files);
|
||||
}
|
||||
/** 下载只允许已完成命令持久化的文件引用,不能凭共享文件 ID 访问。 */
|
||||
@Test void evidenceDownloadRequiresPersistedConfirmation(){
|
||||
assertThatThrownBy(()->service.evidence("PC1",77L)).hasMessageContaining("没有该完成凭证");verifyNoInteractions(files);
|
||||
}
|
||||
/** 构造固定版本的测试执行命令。 */
|
||||
private TransportTaskModels.Command command(String key,List<Long> files){return new TransportTaskModels.Command(3L,key,"结束运输",files);}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.lastleg.fleet.business;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FleetLooseboardDemandPersistenceTest {
|
||||
/** 真实 SQL 验证同客户关联、唯一来源、取消恢复候选和跨客户隔离。 */
|
||||
@Test void originalDemandLeavesPoolUntilDraftIsCancelled() throws Exception {
|
||||
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
var jdbc=new JdbcTemplate(ds);
|
||||
jdbc.execute("CREATE TABLE ll_local_looseboard_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),order_no VARCHAR(64),status VARCHAR(32),lastleg_order_id BIGINT,origin VARCHAR(64),destination VARCHAR(64),created_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_external_booking(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),source_looseboard_order_id BIGINT,status VARCHAR(32))");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_pool_order_link(customer_id VARCHAR(128),business_type VARCHAR(32),business_order_id BIGINT)");
|
||||
jdbc.update("INSERT INTO ll_local_looseboard_order VALUES(1,'C-1','LB-1','PENDING_ACCEPTANCE',NULL,'LA','Ontario',CURRENT_TIMESTAMP)");
|
||||
jdbc.update("INSERT INTO ll_local_looseboard_order VALUES(2,'C-1','OLD','PENDING_ACCEPTANCE',99,'LA','Ontario',CURRENT_TIMESTAMP)");
|
||||
jdbc.update("INSERT INTO ll_fleet_external_booking VALUES(10,'C-1',NULL,'DRAFT'),(11,'C-2',NULL,'DRAFT')");
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
|
||||
config.addMapper(FleetLooseboardDemandMapper.class);config.addMapper(FleetPoolOrderMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
||||
var session=new SqlSessionTemplate(factory.getObject());
|
||||
var mapper=session.getMapper(FleetLooseboardDemandMapper.class);
|
||||
var links=session.getMapper(FleetPoolOrderMapper.class);
|
||||
assertThat(mapper.pending(Page.of(1,20),"Ontario").getRecords()).extracting("orderNo").containsExactly("LB-1");
|
||||
assertThat(mapper.attach(10,"C-2",1)).isZero();
|
||||
assertThat(mapper.attach(10,"C-1",1)).isEqualTo(1);
|
||||
assertThat(mapper.attach(10,"C-1",2)).isZero();
|
||||
assertThat(mapper.active("C-1",1)).isEqualTo(1);
|
||||
assertThat(mapper.active("C-2",1)).isNull();
|
||||
assertThat(links.linked("C-1","LOCAL_LOOSEBOARD",1)).isEqualTo(1);
|
||||
assertThat(mapper.source(10,"C-2")).isNull();
|
||||
assertThat(mapper.source(10,"C-1").getOrderNo()).isEqualTo("LB-1");
|
||||
assertThat(mapper.pending(Page.of(1,20),null).getRecords()).isEmpty();
|
||||
jdbc.update("UPDATE ll_fleet_external_booking SET status='CANCELLED' WHERE id=10");
|
||||
assertThat(mapper.active("C-1",1)).isNull();
|
||||
assertThat(links.linked("C-1","LOCAL_LOOSEBOARD",1)).isZero();
|
||||
assertThat(mapper.pending(Page.of(1,20),null).getRecords()).extracting("orderNo").containsExactly("LB-1");
|
||||
assertThat(mapper.sourceNumbers(List.of(10L,11L))).extracting(FleetPoolOrderLink::getBusinessOrderNo).containsExactly("LB-1");
|
||||
jdbc.update("UPDATE ll_fleet_external_booking SET source_looseboard_order_id=1 WHERE id=11");
|
||||
assertThat(mapper.source(11,"C-2")).isNull();
|
||||
assertThat(mapper.sourceNumbers(List.of(11L))).isEmpty();
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.baiyue.lastleg.fleet.business;
|
||||
|
||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FleetLooseboardDemandServiceTest {
|
||||
final FleetLooseboardDemandRepository repository=mock(FleetLooseboardDemandRepository.class);
|
||||
final FleetLooseboardDemandService service=new FleetLooseboardDemandService(repository);
|
||||
/** 校验客户隔离、版本、已有匹配与独立履约都阻止重复接单。 */
|
||||
@Test void rejectsMissingStaleMatchedAndPhysicalSources() {
|
||||
assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).hasMessageContaining("不存在");
|
||||
var row=source();when(repository.lock("9","LB-1")).thenReturn(row);
|
||||
assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",1L)).hasMessageContaining("变化");
|
||||
assertThatThrownBy(()->service.prepare("9","US_TRUCK","LB-1",0L)).hasMessageContaining("散板");
|
||||
when(repository.active("9",1)).thenReturn(true);
|
||||
assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).hasMessageContaining("已有承运");
|
||||
when(repository.active("9",1)).thenReturn(false);row.setLastlegOrderId(7L);
|
||||
assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).hasMessageContaining("独立履约");
|
||||
verify(repository,never()).attach(anyLong(),anyString(),anyLong());
|
||||
}
|
||||
/** 取消草稿使生效匹配清空,同张原单可重新选车且没有商业复制。 */
|
||||
@Test void allowsRematchAfterDraftCancellationAndKeepsSourceImmutable() {
|
||||
var row=source();when(repository.lock("9","LB-1")).thenReturn(row);
|
||||
when(repository.active("9",1)).thenReturn(true,false);
|
||||
assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).hasMessageContaining("已有承运");
|
||||
assertThat(service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).isSameAs(row);
|
||||
when(repository.attach(7,"9",1)).thenReturn(1);service.attach(7,row);
|
||||
when(repository.source(7,"9")).thenReturn(row);
|
||||
service.validateEdit(7,"9","LOCAL_LOOSEBOARD",null);
|
||||
assertThatThrownBy(()->service.validateEdit(7,"9","US_TRUCK",null)).hasMessageContaining("不能替换");
|
||||
assertThatThrownBy(()->service.validateEdit(7,"9","LOCAL_LOOSEBOARD","LB-2")).hasMessageContaining("不能替换");
|
||||
}
|
||||
/** 构造待匹配原单。 */
|
||||
private LocalLooseboardOrder source() {
|
||||
var row=new LocalLooseboardOrder();row.setId(1L);row.setCustomerId("9");row.setOrderNo("LB-1");row.setVersion(0L);row.setStatus("PENDING_ACCEPTANCE");return row;
|
||||
}
|
||||
}
|
||||
+19
@@ -25,11 +25,13 @@ class FleetPoolOrderBridgeTest {
|
||||
FleetPoolOrderNumberGenerator numbers=mock(FleetPoolOrderNumberGenerator.class);
|
||||
FleetPoolDeliveryEvidence evidence=mock(FleetPoolDeliveryEvidence.class);
|
||||
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker.class);
|
||||
FleetLooseboardDemandRepository demands=mock(FleetLooseboardDemandRepository.class);
|
||||
FleetPoolOrderBridge service;
|
||||
FleetPoolOrderLink row; LocalLooseboardOrder looseOrder; UsTruckOrder truckOrder;
|
||||
@BeforeEach void setup(){
|
||||
service=new FleetPoolOrderBridge(new FleetPoolOrderRepository(links,loose,looseHistory,trucks,truckHistory),numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources);
|
||||
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"demands",demands);
|
||||
when(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001");
|
||||
when(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row);
|
||||
when(links.lockIncoming("9","WT-7")).thenAnswer(a->row);
|
||||
@@ -45,6 +47,23 @@ class FleetPoolOrderBridgeTest {
|
||||
BigDecimal.TEN,BigDecimal.ONE,"USD","联系人","123456","本轮池订单");}
|
||||
void incoming(){service.bindIncoming("FB-7","9","WT-7",77,"92","FBA:ONT8","op");}
|
||||
|
||||
/** 原单匹配后全链复用客户商业单,实收到签收均不另建商业单。 */
|
||||
@Test void matchedSourceRemainsTheSameCommercialOrderThroughDelivery() {
|
||||
looseOrder=new LocalLooseboardOrder();looseOrder.setId(31L);looseOrder.setCustomerId("9");looseOrder.setOrderNo("LB-ORIGINAL");looseOrder.setVersion(4L);looseOrder.setStatus("PENDING_ACCEPTANCE");
|
||||
looseOrder.setOrigin("客户原始提货地址");looseOrder.setDestinationContactName("原始收货人");
|
||||
when(demands.source(7,"9")).thenReturn(looseOrder);when(loose.lockNo("9","LB-ORIGINAL")).thenAnswer(a->looseOrder);
|
||||
var original=looseOrder;
|
||||
var confirmed=service.confirm(input("LOCAL_LOOSEBOARD"),"op");
|
||||
assertThat(confirmed.businessOrderNo()).isEqualTo("LB-ORIGINAL");assertThat(row.getBusinessOrderId()).isEqualTo(31L);
|
||||
assertThat(service.confirm(input("LOCAL_LOOSEBOARD"),"op")).isEqualTo(confirmed);
|
||||
incoming();service.receiving("9","WT-7","pda");service.acceptedReceipt(7,receipt(),3,"op");service.boundDispatch(7,"9","DISPATCH-7","op");
|
||||
service.departed("9","DISPATCH-7",Map.of(77L,4),"op",LocalDateTime.now());
|
||||
service.signed(delivery(4),Map.of(77L,4L),"op",LocalDateTime.now());
|
||||
assertThat(looseOrder).isSameAs(original);assertThat(original.getStatus()).isEqualTo("COMPLETED");
|
||||
assertThat(original.getLastlegOrderId()).isEqualTo(77L);assertThat(original.getDestinationContactName()).isEqualTo("原始收货人");
|
||||
verify(loose,never()).insert(any(LocalLooseboardOrder.class));
|
||||
}
|
||||
|
||||
@Test void materializesTheOriginalAppointmentAfterActualIncomingAssociationAndNeverOnReplay() {
|
||||
service.confirm(input("LOCAL_LOOSEBOARD"),"op");
|
||||
incoming();
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ class FleetTripApiContractTest {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
assertThat(count).isEqualTo(46);
|
||||
assertThat(count).isEqualTo(47);
|
||||
verifyRefs(document, document);
|
||||
verifyRefs(preparation, preparation);
|
||||
verifyRefs(pool, pool);
|
||||
|
||||
+16
@@ -81,6 +81,18 @@ class FleetTripAuthorizationTest {
|
||||
assertThatThrownBy(()->pool.saveAndConfirm("FB-1",null)).isInstanceOf(AccessDeniedException.class);
|
||||
} finally {SecurityContextHolder.clearContext();}
|
||||
}
|
||||
/** 需求含客户原单资料,仅列表权限不能读取待匹配需求。 */
|
||||
@Test void pendingDemandRequiresManageOrListAndCreate() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller=context.getBean(PlatformFleetCapacityOfferController.class);
|
||||
var service=context.getBean(FleetCapacityOfferService.class);
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("sales","unused",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.LIST))));
|
||||
assertThatThrownBy(()->controller.pendingDemands(null)).isInstanceOf(AccessDeniedException.class);
|
||||
verifyNoInteractions(service);
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("sales","unused",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.LIST),new SimpleGrantedAuthority("platform:fleet:capacity-offer:inquiry-create"))));
|
||||
controller.pendingDemands(null);verify(service).pendingDemands(null);
|
||||
} finally {SecurityContextHolder.clearContext();}
|
||||
}
|
||||
@Test void externalArrangeRequiresCurrentCreateAndAllIndependentPermissions() {
|
||||
var required=List.of("platform:fleet:capacity-offer:manage","platform:fleet:trip:booking-edit","platform:fleet:trip:edit","platform:overseas-warehouse:dispatch:create");
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
@@ -100,6 +112,10 @@ class FleetTripAuthorizationTest {
|
||||
}
|
||||
@Configuration @EnableMethodSecurity
|
||||
static class Config {
|
||||
/** 原单需求依赖使用替身,权限测试不访问数据库。 */
|
||||
@Bean com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService looseboardDemands() { return mock(com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService.class); }
|
||||
/** 桥接器的原单查询使用仓储替身。 */
|
||||
@Bean com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository looseboardDemandRepository() { return mock(com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository.class); }
|
||||
/** 既有取货能力依赖原仓储引用;权限测试使用只读替身,不连接数据库。 */
|
||||
@Bean com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups() { return mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); }
|
||||
@Bean com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workTripChanges() { return mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class); }
|
||||
|
||||
+6
-7
@@ -64,6 +64,9 @@ class LocalLooseboardServiceTest {
|
||||
ReflectionTestUtils.setField(repository, "lastlegHistories", lastlegHistories);
|
||||
ReflectionTestUtils.setField(service, "lastlegOrderNoGenerator", lastlegOrderNoGenerator);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
||||
// 旧独立履约用例保留真实关联尾程,新增需求不创建该来源。
|
||||
var legacyTail = new LastlegOrder(); legacyTail.setId(99L); legacyTail.setCustomerId("100");
|
||||
org.mockito.Mockito.lenient().when(lastlegOrders.selectById(99L)).thenReturn(legacyTail);
|
||||
org.mockito.Mockito.lenient().when(lastlegOrderNoGenerator.next(anyString(), any(), anyLong()))
|
||||
.thenReturn("LB-WH-20260901-000001");
|
||||
org.mockito.Mockito.lenient().when(operations.findKey(anyString(), any(), anyString()))
|
||||
@@ -94,12 +97,8 @@ class LocalLooseboardServiceTest {
|
||||
assertEquals("家居用品", detail.summary().productName());
|
||||
assertEquals(3, detail.summary().estimatedPalletCount());
|
||||
assertEquals(LocalDateTime.of(2030, 1, 2, 9, 30), detail.summary().pickupAt());
|
||||
verify(lastlegOrders).insert(org.mockito.ArgumentMatchers.<LastlegOrder>argThat(value ->
|
||||
"LOCAL_LOOSEBOARD".equals(value.getOrderType())
|
||||
&& value.getSourceOrderNo().equals("LB-WH-20260901-000001")
|
||||
&& value.getInboundCode() == null
|
||||
&& value.getPalletCount() == 0
|
||||
&& "LOCAL_LOOSEBOARD:PENDING_ACCEPTANCE".equals(value.getSubOrderStatus())));
|
||||
org.junit.jupiter.api.Assertions.assertNull(saved.get().getLastlegOrderId());
|
||||
verify(lastlegOrders, org.mockito.Mockito.never()).insert(org.mockito.ArgumentMatchers.<LastlegOrder>any());
|
||||
verify(histories).insert(org.mockito.ArgumentMatchers.<LocalLooseboardHistory>argThat(value ->
|
||||
"CREATE".equals(value.getOperationType())));
|
||||
}
|
||||
@@ -208,7 +207,7 @@ class LocalLooseboardServiceTest {
|
||||
|
||||
private LocalLooseboardOrder order(String status, long version) {
|
||||
LocalLooseboardOrder order = new LocalLooseboardOrder();
|
||||
order.setId(1L); order.setCustomerId("100"); order.setOrderNo("LB1");
|
||||
order.setId(1L); order.setCustomerId("100"); order.setOrderNo("LB1"); order.setLastlegOrderId(99L);
|
||||
order.setPalletRequired("YES");
|
||||
order.setTransportType("WAREHOUSE_TO_WAREHOUSE"); order.setOrigin("Los Angeles");
|
||||
order.setDestination("Ontario"); order.setPackageCount(12); order.setCurrency("USD");
|
||||
|
||||
@@ -57,9 +57,19 @@
|
||||
],
|
||||
"reviewedClassDependencies": [
|
||||
{
|
||||
"source": "com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels",
|
||||
"target": "com.baiyue.lastleg.dispatch.port.DispatchViews",
|
||||
"reason": "外配装车后视图组合原排车公开只读详情,复用同一履约事实,不读取实体或写主单。"
|
||||
"source": "com.baiyue.lastleg.dispatch.transport.TransportTaskService",
|
||||
"target": "com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository",
|
||||
"reason": "本土卡车接入现有排车的本地事务编排:创建和执行先锁原单,防止普通原单命令并发推进;仅允许此编排器通过Repository访问,不批准Mapper或跨服务SQL。"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.lastleg.dispatch.transport.TransportTaskService",
|
||||
"target": "com.baiyue.lastleg.trucking.entity.UsTruckOrder",
|
||||
"reason": "同一锁单事务核对本土卡车真实起终点、实收托数和申报载重容积,不能用浏览器载荷替代原单;不构造库存或向API暴露原单写模型。"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.lastleg.dispatch.transport.TransportTaskProjection",
|
||||
"target": "com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository",
|
||||
"reason": "同步运输事件与本土卡车状态必须同事务提交和回滚,旧内部执行入口也要同步原单并追加原单历史;取消释放有效原单关联,不合并客户和仓库归属。"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels",
|
||||
@@ -360,6 +370,26 @@
|
||||
"source": "com.baiyue.lastleg.inboundplan.work.FbaAppointmentTransportAdapter",
|
||||
"target": "com.baiyue.lastleg.dispatch.port.DispatchTypes",
|
||||
"reason": "既有预约整车接线按已确认的排车统一作业契约调用JDK内部变更端口及公共命令/事实/枚举;仅批准此Adapter到此公共类型,不开放主流程实现、持久化或新增预约权威。"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.lastleg.fleet.business.FleetLooseboardDemandMapper",
|
||||
"target": "com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder",
|
||||
"reason": "客户原始散板需求匹配已有车次,确认承运复用原单;不跨服务读库、不创建第二商业单"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository",
|
||||
"target": "com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder",
|
||||
"reason": "客户原始散板需求匹配已有车次,确认承运复用原单;不跨服务读库、不创建第二商业单"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository",
|
||||
"target": "com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper",
|
||||
"reason": "客户原始散板需求匹配已有车次,确认承运复用原单;不跨服务读库、不创建第二商业单"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService",
|
||||
"target": "com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder",
|
||||
"reason": "客户原始散板需求匹配已有车次,确认承运复用原单;不跨服务读库、不创建第二商业单"
|
||||
}
|
||||
],
|
||||
"applicationDependencyRule": {
|
||||
|
||||
Reference in New Issue
Block a user