This commit is contained in:
2026-10-19 10:23:09 +08:00
parent 45fbd6eff8
commit e8f6bb32dc
30 changed files with 1612 additions and 49 deletions
@@ -0,0 +1,72 @@
package com.baiyue.lastleg.fleet.pickup;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 取货持久化只访问 Lastleg 自有库,客户与原业务号始终成对使用。 */
@Mapper
public interface FleetPickupMapper extends BaseMapper<FleetPickupTask> {
String ELIGIBLE = " FROM ll_warehouse_transfer_order w LEFT JOIN ll_fleet_pickup_task p ON p.transfer_order_id=w.id "
+ "WHERE w.delivery_method='WAREHOUSE_PICKUP' AND w.transfer_no=w.inbound_code "
+ "AND w.status='WAIT_PDA_RECEIVING' AND (p.transfer_order_id IS NULL OR p.trip_no IS NULL) "
+ "AND (#{keyword} IS NULL OR w.transfer_no LIKE CONCAT('%',#{keyword},'%') OR w.delivery_address LIKE CONCAT('%',#{keyword},'%')) ";
/** 分页读取已经审核、尚未安排的上门取货需求。 */
@Select("SELECT w.customer_id,w.transfer_no,w.lastleg_order_no,w.operating_warehouse_id AS warehouse_id,"
+ "w.delivery_address AS pickup_address,w.pickup_contact_name AS contact_name,w.pickup_contact_phone AS contact_phone,"
+ "w.package_count,w.estimated_arrival_at AS expected_arrival_at,NULL AS warehouse_code,NULL AS warehouse_name" + ELIGIBLE + "ORDER BY w.created_at,w.id LIMIT #{limit} OFFSET #{offset}")
List<FleetPickupModels.Candidate> candidates(@Param("keyword") String keyword,@Param("limit") int limit,@Param("offset") int offset);
/** 与分页使用同一资格条件,避免浏览器用当前页推总数。 */
@Select("SELECT COUNT(*)" + ELIGIBLE)
long countCandidates(@Param("keyword") String keyword);
/** 锁定源订单,串行化补箱、清点与取货任务关联。 */
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number} FOR UPDATE")
WarehouseTransferOrder lockSource(@Param("customer") String customer,@Param("number") String number);
/** 读取明确客户下的源订单,不接收来自客户端的客户范围推断。 */
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number}")
WarehouseTransferOrder source(@Param("customer") String customer,@Param("number") String number);
/** PDA 作业仍按已绑定海外仓锁定原单。 */
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE operating_warehouse_id=#{warehouse} AND transfer_no=#{number} FOR UPDATE")
WarehouseTransferOrder lockPdaSource(@Param("warehouse") String warehouse,@Param("number") String number);
/** 读取取货任务,原订单锁负责同票并发互斥。 */
@Select("SELECT * FROM ll_fleet_pickup_task WHERE transfer_order_id=#{id}")
FleetPickupTask task(@Param("id") long id);
/** 同票安排在原单锁后使用当前读,不受早先查询快照影响。 */
@Select("SELECT * FROM ll_fleet_pickup_task WHERE transfer_order_id=#{id} FOR UPDATE")
FleetPickupTask lockTask(@Param("id") long id);
/** 一车多票按原订单编号稳定排序。 */
@Select("SELECT * FROM ll_fleet_pickup_task WHERE trip_no=#{tripNo} ORDER BY transfer_order_id")
List<FleetPickupTask> tasks(@Param("tripNo") String tripNo);
/** 解除计划中的取货关联,保留历史后允许再次安排。 */
@Update("UPDATE ll_fleet_pickup_task SET trip_no=NULL,status='WAITING',updated_at=CURRENT_TIMESTAMP WHERE trip_no=#{tripNo} AND status='ASSIGNED'")
int unassign(@Param("tripNo") String tripNo);
/** 不可变操作历史覆盖安排、解除、取货、到仓及结案。 */
@Insert("INSERT INTO ll_fleet_pickup_history(customer_id,transfer_no,trip_no,action,operator_id,occurred_at,detail) VALUES(#{customer},#{number},#{trip},#{action},#{operator},CURRENT_TIMESTAMP,#{detail})")
int history(@Param("customer") String customer,@Param("number") String number,@Param("trip") String trip,
@Param("action") String action,@Param("operator") String operator,@Param("detail") String detail);
/** 关联取货后禁止再把同一票登记为另一条拼车来货。 */
@Select("SELECT COUNT(*) FROM ll_fleet_pool_order_link p JOIN ll_fleet_external_booking b ON b.id=p.booking_id WHERE p.customer_id=#{customer} AND p.incoming_no=#{number} AND b.status!='CANCELLED'")
long poolLinks(@Param("customer") String customer,@Param("number") String number);
/** 创建请求保存完整来源摘要,原单后续变化不影响未知结果重试。 */
@Insert("INSERT INTO ll_fleet_pickup_plan(trip_no,request_hash) VALUES(#{trip},#{hash})")
int insertPlan(@Param("trip") String trip,@Param("hash") String hash);
/** 查询已提交的取货计划请求。 */
@Select("SELECT request_hash FROM ll_fleet_pickup_plan WHERE trip_no=#{trip}")
String planHash(@Param("trip") String trip);
/** 车次行锁内核对同键同内容。 */
@Select("SELECT request_hash FROM ll_fleet_pickup_command WHERE trip_no=#{trip} AND request_key=#{key}")
String commandHash(@Param("trip") String trip,@Param("key") String key);
/** 命令结果与取货业务变更同事务提交。 */
@Insert("INSERT INTO ll_fleet_pickup_command(trip_no,request_key,request_hash) VALUES(#{trip},#{key},#{hash})")
int command(@Param("trip") String trip,@Param("key") String key,@Param("hash") String hash);
/** 上传阶段保存精确单据归属,避免同车不同客户的凭证串票。 */
@Insert("INSERT INTO ll_fleet_pickup_upload(transfer_order_id,trip_no,file_id) VALUES(#{source},#{trip},#{file})")
int upload(@Param("source") long source,@Param("trip") String trip,@Param("file") long file);
/** 只能引用本票本车次已上传的文件。 */
@Select("SELECT COUNT(*) FROM ll_fleet_pickup_upload WHERE transfer_order_id=#{source} AND trip_no=#{trip} AND file_id=#{file}")
int ownsUpload(@Param("source") long source,@Param("trip") String trip,@Param("file") long file);
/** 发车时核对车辆司机仍被本车次实际占用。 */
@Select("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE trip_id=#{trip} AND ((resource_kind='VEHICLE' AND resource_id=#{vehicle}) OR (resource_kind='DRIVER' AND resource_id=#{driver}))")
int resources(@Param("trip") long trip,@Param("vehicle") long vehicle,@Param("driver") long driver);
}
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.fleet.pickup;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
/** 入仓取货的请求与投影,不把取货箱数当成仓库实收。 */
public final class FleetPickupModels {
/** 工具类不允许实例化。 */
private FleetPickupModels() { }
public record Source(@NotBlank String customerId, @NotBlank String transferNo) { }
public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size, @Size(max=128) String keyword) {
/** 缺省分页只读取当前工作列表。 */
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
}
public record Candidate(String customerId, String transferNo, String lastlegOrderNo, String warehouseId,
String pickupAddress, String contactName, String contactPhone, int packageCount,
LocalDateTime expectedArrivalAt, String warehouseCode, String warehouseName) { }
public record Create(@NotNull @Size(min=1,max=100) List<@Valid Source> sources,
@NotNull Instant plannedDepartureAt, @NotNull Instant expectedDriverEndAt,
@Size(max=1000) String remark,
@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) { }
public record Pickup(@NotNull @Min(0) Long recordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey,
@Min(1) @Max(5000) int actualPackages,
@NotNull @Size(min=1,max=10) List<@NotNull @Positive Long> evidenceFileIds,
@Size(max=1000) String remark) { }
public record Task(String customerId, String transferNo, String lastlegOrderNo, String warehouseId,
String pickupAddress, String contactName, String contactPhone, int packageCount,
String status, String tripNo, String vehiclePlate, String driverName, String driverPhone,
Instant plannedDepartureAt, Integer actualPackages, Instant pickedAt, Instant arrivedAt,
String remark, List<Evidence> evidence) { }
public record Evidence(String fileId, String fileName, String contentType, long sizeBytes) { }
public record History(String action, String tripNo, String operatorId, Instant occurredAt, String detail) { }
}
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.fleet.pickup;
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
import com.baiyue.lastleg.fleet.trip.FleetTripMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 封装取货单与同库车次的持久化,不承载状态流转。 */
@Repository
public class FleetPickupRepository {
private final FleetPickupMapper mapper;
private final FleetTripMapper trips;
/** 注入所属域的 Mapper。 */
public FleetPickupRepository(FleetPickupMapper mapper,FleetTripMapper trips) { this.mapper=mapper;this.trips=trips; }
/** 查询当前分页候选。 */
public List<FleetPickupModels.Candidate> candidates(String keyword,int limit,int offset) { return mapper.candidates(keyword,limit,offset); }
/** 查询匹配需求总数。 */
public long count(String keyword) { return mapper.countCandidates(keyword); }
/** 读取或锁定客户原单。 */
public WarehouseTransferOrder source(String customer,String number,boolean lock) { return lock?mapper.lockSource(customer,number):mapper.source(customer,number); }
/** 按 PDA 仓库范围锁定原单。 */
public WarehouseTransferOrder pdaSource(String warehouse,String number) { return mapper.lockPdaSource(warehouse,number); }
/** 读取原单唯一取货记录。 */
public FleetPickupTask task(long id) { return mapper.task(id); }
/** 读取车次全部取货成员。 */
public List<FleetPickupTask> tasks(String number) { return mapper.tasks(number); }
/** 写入首次安排。 */
public int insert(FleetPickupTask task) { return mapper.insert(task); }
/** 保存已有取货执行记录。 */
public int update(FleetPickupTask task) { return mapper.updateById(task); }
/** 读取或锁定共享车次。 */
public FleetTripEntity trip(String number,boolean lock) { return lock?trips.lockByNo(number):trips.findByNo(number); }
/** 按原幂等键查询已创建车次。 */
public FleetTripEntity created(String operator,String key) { return trips.findCreated(operator,key); }
/** 创建资格必须以锁定后的当前记录为准。 */
public FleetPickupTask lockTask(long id) { return mapper.lockTask(id); }
/** 释放未开始的成员关联。 */
public int unassign(String number) { return mapper.unassign(number); }
/** 保存不可变业务操作记录。 */
public void history(FleetPickupTask task,String action,String operator,String detail) { mapper.history(task.getCustomerId(),task.getTransferNo(),task.getTripNo(),action,operator,detail); }
/** 核对旧拼车承运关系,防止同票走两条运输链。 */
public boolean hasPoolLink(String customer,String number) { return mapper.poolLinks(customer,number)>0; }
/** 写入取货计划来源摘要。 */
public void insertPlan(String trip,String hash) { mapper.insertPlan(trip,hash); }
/** 读取计划原请求摘要。 */
public String planHash(String trip) { return mapper.planHash(trip); }
/** 读取命令的幂等摘要。 */
public String commandHash(String trip,String key) { return mapper.commandHash(trip,key); }
/** 保存已成功执行的命令。 */
public void command(String trip,String key,String hash) { mapper.command(trip,key,hash); }
/** 保存本票的上传归属。 */
public void upload(long source,String trip,long file) { mapper.upload(source,trip,file); }
/** 核对凭证是否属于同票同车次。 */
public boolean ownsUpload(long source,String trip,long file) { return mapper.ownsUpload(source,trip,file)==1; }
/** 两种资源须同时属于当前车次。 */
public boolean ownsResources(long trip,long vehicle,long driver) { return mapper.resources(trip,vehicle,driver)==2; }
}
@@ -0,0 +1,269 @@
package com.baiyue.lastleg.fleet.pickup;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.fleet.driver.DriverService;
import com.baiyue.lastleg.fleet.driver.FleetWarehouseDirectory;
import com.baiyue.lastleg.fleet.reporting.TripOperationsEvidenceGateway;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.*;
import java.util.*;
/** 入仓取货只记录运输事实,不创建第二份订单、不写库存和测量完成状态。 */
@Service
public class FleetPickupService {
private final FleetPickupRepository repository;
private final FleetTripService trips;
private final FleetWarehouseDirectory warehouses;
private final DriverService drivers;
private final VehicleRepository vehicles;
private final TripOperationsEvidenceGateway files;
private final ObjectMapper json;
/** 共用现有车次、司机资格、车辆占用及鉴权文件能力。 */
public FleetPickupService(FleetPickupRepository repository,FleetTripService trips,FleetWarehouseDirectory warehouses,
DriverService drivers,VehicleRepository vehicles,TripOperationsEvidenceGateway files,ObjectMapper json) {
this.repository=repository;this.trips=trips;this.warehouses=warehouses;this.drivers=drivers;
this.vehicles=vehicles;this.files=files;this.json=json;
}
/** 审核通过的原单自动出现在候选中,客户自送和库存转运不参与。 */
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<FleetPickupModels.Candidate> candidates(FleetPickupModels.Query query) {
String keyword=blank(query.keyword());long count=repository.count(keyword);
var rows=repository.candidates(keyword,query.size(),Math.multiplyExact(query.page()-1,query.size()));
var directory=warehouses.options("OVERSEAS").stream().collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.driver.DriverModels.WarehouseOption::id,v->v));
var enriched=rows.stream().map(row->{var warehouse=directory.get(row.warehouseId());
return new FleetPickupModels.Candidate(row.customerId(),row.transferNo(),row.lastlegOrderNo(),row.warehouseId(),row.pickupAddress(),row.contactName(),row.contactPhone(),row.packageCount(),row.expectedArrivalAt(),warehouse==null?null:warehouse.code(),warehouse==null?"仓库已停用":warehouse.name());}).toList();
return new PageResult<>(enriched,
query.page(),query.size(),count,(count+query.size()-1)/query.size());
}
/** 锁定源订单创建同仓多点取货车次,任一来源失败时整车回滚。 */
@Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public FleetTripModels.Detail create(String operator,FleetPickupModels.Create request) {
String requestHash=hash(write(request));
var prior=repository.created(operator,request.idempotencyKey());
if(prior!=null) {
if(!"PICKUP".equals(prior.getTripPurpose()) || !requestHash.equals(repository.planHash(prior.getTripNo())))
throw conflict("同一安排请求不能更换来源或时间");
return trips.detail(prior.getTripNo());
}
var sources=request.sources().stream().sorted(Comparator.comparing(FleetPickupModels.Source::customerId)
.thenComparing(FleetPickupModels.Source::transferNo)).toList();
if(new HashSet<>(sources).size()!=sources.size())throw bad("同一取货单不能重复选择");
var orders=new ArrayList<WarehouseTransferOrder>();
for(var source:sources) {
var order=source(source.customerId(),source.transferNo(),true);eligible(order);
// 相同请求并发等待原单锁后,允许返回第一次已经成功的车次。
var committed=repository.created(operator,request.idempotencyKey());
if(committed!=null) {
if(!"PICKUP".equals(committed.getTripPurpose()) || !requestHash.equals(repository.planHash(committed.getTripNo())))throw conflict("同一安排请求不能更换来源或时间");
return trips.detail(committed.getTripNo());
}
var task=repository.lockTask(order.getId());
if(task!=null && task.getTripNo()!=null)throw conflict("转运单已安排车次:"+task.getTripNo());
if(repository.hasPoolLink(source.customerId(),source.transferNo()))throw conflict("该单已有拼车承运安排,不能重复安排取货");
orders.add(order);
}
String warehouseId=orders.get(0).getOperatingWarehouseId();
if(orders.stream().anyMatch(order->!warehouseId.equals(order.getOperatingWarehouseId())))throw bad("同车取货必须送往同一海外仓");
var warehouse=warehouses.required(Long.valueOf(warehouseId),"OVERSEAS");
// 站点按原订单稳定生成,最后一站固定为客户选择的收货仓。
var stops=new ArrayList<FleetTripModels.Stop>();
for(var order:orders)stops.add(new FleetTripModels.Stop(order.getTransferNo(),order.getDeliveryAddress(),null));
stops.add(new FleetTripModels.Stop(warehouse.name(),null,"PICKUP_WAREHOUSE:"+warehouseId));
var created=trips.createPickup(operator,new FleetTripModels.Create(Long.valueOf(warehouseId),request.plannedDepartureAt(),
request.expectedDriverEndAt(),stops,request.remark(),request.idempotencyKey()));
var trip=repository.trip(created.trip().tripNo(),true);
repository.insertPlan(trip.getTripNo(),requestHash);
for(var order:orders) {
var task=repository.task(order.getId());boolean fresh=task==null;
if(fresh) { task=new FleetPickupTask();task.setTransferOrderId(order.getId());task.setCustomerId(order.getCustomerId());task.setTransferNo(order.getTransferNo()); }
task.setTripNo(trip.getTripNo());task.setStatus("ASSIGNED");task.setUpdatedAt(now());
if(fresh)repository.insert(task);else repository.update(task);
repository.history(task,"ASSIGN",operator,"安排仓储转运上门取货");
}
trips.recordExecution(trip,trip.getRecordVersion(),"PICKUP_PLAN","关联原转运单取货",operator);
return trips.detail(trip.getTripNo());
}
/** 按车次读取取货成员,只有专用用途的车次能进入此链路。 */
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public List<FleetPickupModels.Task> tasks(String tripNo) {
pickupTrip(tripNo,false);
return repository.tasks(tripNo).stream().map(task->project(source(task.getCustomerId(),task.getTransferNo(),false),task)).toList();
}
/** 客户和平台详情读取同一取货进度,但只返回当前票的凭证。 */
@Transactional(readOnly=true)
public FleetPickupModels.Task progress(String customer,String number) {
var source=source(customer,number,false);
if(!"WAREHOUSE_PICKUP".equals(source.getDeliveryMethod()) || !source.getTransferNo().equals(source.getInboundCode()))return null;
return project(source,repository.task(source.getId()));
}
/** PDA 开始作业前校验已送仓,防止把司机取货冒充仓库实收。 */
public WarehouseTransferOrder requireMeasuringSource(String warehouse,String number) {
var source=repository.pdaSource(warehouse,number);
if(source==null)throw missing("仓储转运单不存在");
if(!"WAIT_PDA_RECEIVING".equals(source.getStatus()))throw conflict("当前转运单不允许测量");
requireArrived(source);
return source;
}
/** 已有原单行锁的清点、照片和完成入口复用相同到仓门禁。 */
public void requireArrived(WarehouseTransferOrder source) {
if("WAREHOUSE_PICKUP".equals(source.getDeliveryMethod()) && source.getTransferNo().equals(source.getInboundCode())) {
var task=repository.task(source.getId());
if(task==null || !"ARRIVED".equals(task.getStatus()))throw conflict("该单为仓库上门取货,请先完成车辆取货送仓");
}
}
/** 分配后复验车辆和司机,发车不触发任何出库扣库存。 */
@Transactional
public FleetTripModels.Detail depart(String number,String operator,FleetPickupModels.Command request) {
var trip=pickupTrip(number,true);String hash=commandHash("DEPART",request);
if(replayed(trip,request.idempotencyKey(),hash))return trips.detail(number);
version(trip,request.recordVersion());
if(!"PLANNED".equals(trip.getStatus()) || repository.tasks(number).isEmpty())throw conflict("仅有取货任务的计划车次可以发车");
requireResources(trip);
for(var task:repository.tasks(number))eligible(source(task.getCustomerId(),task.getTransferNo(),true));
trip.setStatus("IN_TRANSIT");trip.setDepartedAt(now());
trips.recordExecution(trip,request.recordVersion(),"PICKUP_DEPART",request.reason(),operator);
repository.command(number,request.idempotencyKey(),hash);return trips.detail(number);
}
/** 取货凭证上传只绑定当前票所属车次,上传本身不推进取货状态。 */
@Transactional
public FleetPickupModels.Evidence upload(String number,String customer,String transferNo,String operator,MultipartFile file) {
var trip=pickupTrip(number,true);var task=member(trip,customer,transferNo);
if(!Set.of("PLANNED","IN_TRANSIT").contains(trip.getStatus()))throw conflict("当前车次不能上传取货凭证");
if(file==null || file.isEmpty() || file.getSize()>10L*1024*1024)throw bad("请上传不超过10MB的取货凭证");
var value=files.upload(trip.getId(),operator,file);
repository.upload(task.getTransferOrderId(),number,Long.parseLong(value.fileId()));
return new FleetPickupModels.Evidence(String.valueOf(value.fileId()),value.fileName(),value.contentType(),value.sizeBytes());
}
/** 逐票确认实提箱数与凭证,数量可以与预报不同,留给仓库独立实收。 */
@Transactional
public List<FleetPickupModels.Task> picked(String number,String customer,String transferNo,String operator,FleetPickupModels.Pickup request) {
var trip=pickupTrip(number,true);String hash=commandHash("PICKED:"+customer+":"+transferNo,request);
if(replayed(trip,request.idempotencyKey(),hash))return tasks(number);
version(trip,request.recordVersion());var task=member(trip,customer,transferNo);
if(!"IN_TRANSIT".equals(trip.getStatus()) || !"ASSIGNED".equals(task.getStatus()))throw conflict("请先发车,且该单尚未确认取货");
eligible(source(customer,transferNo,true));
var evidence=request.evidenceFileIds().stream().distinct().map(id->{
if(!repository.ownsUpload(task.getTransferOrderId(),number,id))throw bad("取货凭证不属于当前订单或车次");
var file=files.metadata(trip.getId(),id);
return new FleetPickupModels.Evidence(String.valueOf(file.fileId()),file.fileName(),file.contentType(),file.sizeBytes());
}).toList();
task.setStatus("PICKED");task.setActualPackages(request.actualPackages());task.setPickedAt(now());
task.setEvidenceJson(write(evidence));task.setRemark(blank(request.remark()));task.setUpdatedAt(now());repository.update(task);
repository.history(task,"PICKED",operator,"实提"+request.actualPackages()+"箱;"+Objects.toString(task.getRemark(),""));
trips.recordExecution(trip,request.recordVersion(),"PICKUP_PICKED","确认原单取货",operator);
repository.command(number,request.idempotencyKey(),hash);return tasks(number);
}
/** 到仓确认只开放 PDA 清点,不覆盖预报箱数和 SKU 件数。 */
@Transactional
public List<FleetPickupModels.Task> arrive(String number,String customer,String transferNo,String operator,FleetPickupModels.Command request) {
var trip=pickupTrip(number,true);String hash=commandHash("ARRIVED:"+customer+":"+transferNo,request);
if(replayed(trip,request.idempotencyKey(),hash))return tasks(number);
version(trip,request.recordVersion());var task=member(trip,customer,transferNo);
if(!"IN_TRANSIT".equals(trip.getStatus()) || !"PICKED".equals(task.getStatus()))throw conflict("请先确认该单已取货");
source(customer,transferNo,true);task.setStatus("ARRIVED");task.setArrivedAt(now());task.setUpdatedAt(now());repository.update(task);
repository.history(task,"ARRIVED",operator,request.reason());
trips.recordExecution(trip,request.recordVersion(),"PICKUP_ARRIVED",request.reason(),operator);
repository.command(number,request.idempotencyKey(),hash);return tasks(number);
}
/** 全部送仓后结案运输,车辆和司机仍沿原有入口分别结束任务。 */
@Transactional
public FleetTripModels.Detail complete(String number,String operator,FleetPickupModels.Command request) {
var trip=pickupTrip(number,true);String hash=commandHash("COMPLETE",request);
if(replayed(trip,request.idempotencyKey(),hash))return trips.detail(number);
version(trip,request.recordVersion());var tasks=repository.tasks(number);
if(!"IN_TRANSIT".equals(trip.getStatus()) || tasks.isEmpty() || tasks.stream().anyMatch(task->!"ARRIVED".equals(task.getStatus())))
throw conflict("请先确认本车全部取货订单已送达海外仓");
var completedAt=tasks.stream().map(FleetPickupTask::getArrivedAt).max(Comparator.naturalOrder()).orElseThrow();
trip.setStatus("COMPLETED");trip.setCompletedAt(completedAt);
// 这里保存运输结束凭据;不虚构派送签收单,也不等待仓库测量完成。
trip.setCompletionJson(write(new FleetCompletionModels.Completed(request.idempotencyKey(),hash,completedAt.toInstant(ZoneOffset.UTC),Instant.now(),operator,List.of())));
trips.recordExecution(trip,request.recordVersion(),"PICKUP_COMPLETE",request.reason(),operator);
repository.command(number,request.idempotencyKey(),hash);return trips.detail(number);
}
/** 读取已确认凭证时必须核对具体客户、订单和文件引用,不因共车暴露其他客户照片。 */
public byte[] evidence(String customer,String transferNo,long fileId) {
var order=source(customer,transferNo,false);var task=repository.task(order.getId());
if(task==null || evidence(task).stream().noneMatch(file->file.fileId().equals(String.valueOf(fileId))))throw missing("当前订单没有该取货凭证");
var trip=pickupTrip(task.getTripNo(),false);return files.download(trip.getId(),fileId);
}
/** 组装运输进度,车次资源修改后自然回显最新分配。 */
private FleetPickupModels.Task project(WarehouseTransferOrder source,FleetPickupTask task) {
var trip=task==null || task.getTripNo()==null?null:repository.trip(task.getTripNo(),false);
String status=task==null || task.getTripNo()==null?("PENDING_REVIEW".equals(source.getStatus()) || "DRAFT".equals(source.getStatus())?"PENDING_REVIEW":"WAITING"):task.getStatus();
return new FleetPickupModels.Task(source.getCustomerId(),source.getTransferNo(),source.getLastlegOrderNo(),source.getOperatingWarehouseId(),
source.getDeliveryAddress(),source.getPickupContactName(),source.getPickupContactPhone(),source.getPackageCount(),status,
trip==null?null:trip.getTripNo(),trip==null?null:trip.getVehiclePlate(),trip==null?null:trip.getDriverName(),trip==null?null:trip.getDriverPhone(),
trip==null?null:instant(trip.getPlannedDepartureAt()),task==null?null:task.getActualPackages(),task==null?null:instant(task.getPickedAt()),
task==null?null:instant(task.getArrivedAt()),task==null?null:task.getRemark(),task==null?List.of():evidence(task));
}
/** 解析任务已确认文件清单。 */
private List<FleetPickupModels.Evidence> evidence(FleetPickupTask task) { return task.getEvidenceJson()==null?List.of():read(task.getEvidenceJson(),new TypeReference<>(){}); }
/** 当前车次成员由服务端复核,客户 ID 不能单独授权操作。 */
private FleetPickupTask member(FleetTripEntity trip,String customer,String number) {
var source=source(customer,number,false);var task=repository.task(source.getId());
if(task==null || !trip.getTripNo().equals(task.getTripNo()))throw missing("当前车次没有该取货订单");return task;
}
/** 按客户与单号读取原订单。 */
private WarehouseTransferOrder source(String customer,String number,boolean lock) {
var source=repository.source(customer,number,lock);if(source==null)throw missing("仓储转运单不存在");return source;
}
/** 原业务资格每次执行复核,禁止未审、自送、库存转运和已完成测量单进入。 */
private static void eligible(WarehouseTransferOrder order) {
if(!"WAREHOUSE_PICKUP".equals(order.getDeliveryMethod()) || !order.getTransferNo().equals(order.getInboundCode())
|| !"WAIT_PDA_RECEIVING".equals(order.getStatus()))throw conflict("该单不是已审核待收货的仓库上门取货单");
if(blank(order.getDeliveryAddress())==null || blank(order.getPickupContactName())==null || blank(order.getPickupContactPhone())==null)
throw bad("请补齐原转运单的取货地址、联系人和电话");
}
/** 取货用途与派送用途不允许互相调用执行命令。 */
private FleetTripEntity pickupTrip(String number,boolean lock) {
var trip=repository.trip(number,lock);if(trip==null)throw missing("车次不存在");
if(!"PICKUP".equals(trip.getTripPurpose()))throw conflict("当前车次不是入仓取货车次");return trip;
}
/** 发车复验资源资格,避免分配后车辆停用或证照过期。 */
private void requireResources(FleetTripEntity trip) {
if(trip.getVehicleId()==null || trip.getDriverId()==null)throw conflict("请先分配车辆和司机");
if(!repository.ownsResources(trip.getId(),trip.getVehicleId(),trip.getDriverId()))throw conflict("车辆或司机占用与当前车次不一致,请重新核对分配");
var vehicle=vehicles.lockById(trip.getVehicleId()).orElseThrow(()->conflict("车辆不存在"));
if(!"OVERSEAS".equals(vehicle.region()) || !"IN_SERVICE".equals(vehicle.status()))throw conflict("当前车辆不可出车");
var driver=drivers.requireDispatchable(trip.getDriverId(),instant(trip.getExpectedDriverEndAt()));
if(!"OVERSEAS".equals(driver.details().region()))throw conflict("请选择海外司机");
}
/** 已成功命令同键同内容重放,禁止同键更换动作或订单。 */
private boolean replayed(FleetTripEntity trip,String key,String hash) {
String previous=repository.commandHash(trip.getTripNo(),key);
if(previous!=null && !previous.equals(hash))throw conflict("同一请求标识不能提交不同的取货内容");return previous!=null;
}
/** 当前版本防止调度和现场操作相互覆盖。 */
private static void version(FleetTripEntity trip,long version) { if(trip.getRecordVersion()!=version)throw conflict("车次已变化,请刷新后重试"); }
/** 动作与请求一起摘要,隔离共用请求键。 */
private String commandHash(String action,Object request) { return hash(action+":"+write(request)); }
/** 固定序列化持久化命令和凭证。 */
private String write(Object value) { try{return json.writeValueAsString(value);}catch(Exception error){throw new IllegalStateException("取货数据序列化失败",error);} }
/** 解析服务器保存的凭证数据。 */
private <T>T read(String value,TypeReference<T> type) { try{return json.readValue(value,type);}catch(Exception error){throw new IllegalStateException("取货凭证读取失败",error);} }
/** 创建不可逆请求摘要,不在日志记录完整请求。 */
private static String hash(String value) { try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));}catch(Exception error){throw new IllegalStateException(error);} }
/** 统一以 UTC 保存车次履约时刻。 */
private static LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC).withNano(0); }
/** 转换可空的数据库时刻。 */
private static Instant instant(LocalDateTime value) { return value==null?null:value.toInstant(ZoneOffset.UTC); }
/** 空白输入统一为空。 */
private static String blank(String value) { return value==null || value.isBlank()?null:value.trim(); }
/** 返回明确的业务冲突。 */
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT,message); }
/** 返回参数错误。 */
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,message); }
/** 隐藏不属于当前业务范围的资源。 */
private static ResponseStatusException missing(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND,message); }
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.fleet.pickup;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/** 每票转运单唯一一条取货执行记录,改派历史另行保存。 */
@Data
@TableName("ll_fleet_pickup_task")
public class FleetPickupTask {
@TableId(type=IdType.INPUT) private Long transferOrderId;
private String customerId;
private String transferNo;
@TableField(updateStrategy=FieldStrategy.ALWAYS) private String tripNo;
private String status;
private Integer actualPackages;
private LocalDateTime pickedAt;
private LocalDateTime arrivedAt;
private String evidenceJson;
private String remark;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,81 @@
package com.baiyue.lastleg.fleet.pickup;
import com.baiyue.common.core.api.*;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.trip.FleetTripModels;
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.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
/** 取货调度复用车次既有独立权限,所有入口必须为平台主体。 */
@Validated
@RestController
@RequestMapping("/api/platform/v1/last-leg/fleet/pickups")
public class PlatformFleetPickupController {
private final FleetPickupService service;
/** 注入取货业务编排。 */
public PlatformFleetPickupController(FleetPickupService service) { this.service=service; }
/** 查询自动汇入的待取货需求。 */
@GetMapping
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<PageResult<FleetPickupModels.Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser user,@Valid FleetPickupModels.Query query) {
operator(user);return ApiResponse.success(service.candidates(query));
}
/** 创建一仓多点取货车次,不重复创建尾程单。 */
@PostMapping
@PreAuthorize("hasAuthority('platform:fleet:trip:create')")
public ApiResponse<FleetTripModels.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody FleetPickupModels.Create request) {
return ApiResponse.success(service.create(operator(user),request));
}
/** 查看车次取货成员。 */
@GetMapping("/{tripNo}/tasks")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<List<FleetPickupModels.Task>> tasks(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo) {
operator(user);return ApiResponse.success(service.tasks(tripNo));
}
/** 取货车次发车不进入仓内出库链。 */
@PostMapping("/{tripNo}/depart")
@PreAuthorize("hasAuthority('platform:fleet:trip:assign')")
public ApiResponse<FleetTripModels.Detail> depart(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid @RequestBody FleetPickupModels.Command request) {
return ApiResponse.success(service.depart(tripNo,operator(user),request));
}
/** 记录原订单的取货凭证上传归属。 */
@PostMapping(value="/{tripNo}/tasks/{customerId}/{transferNo}/files",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetPickupModels.Evidence> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,
@PathVariable String customerId,@PathVariable String transferNo,@RequestPart MultipartFile file) {
return ApiResponse.success(service.upload(tripNo,customerId,transferNo,operator(user),file));
}
/** 逐票确认实提,不覆盖客户预报。 */
@PostMapping("/{tripNo}/tasks/{customerId}/{transferNo}/picked")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<List<FleetPickupModels.Task>> picked(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,
@PathVariable String customerId,@PathVariable String transferNo,@Valid @RequestBody FleetPickupModels.Pickup request) {
return ApiResponse.success(service.picked(tripNo,customerId,transferNo,operator(user),request));
}
/** 确认送仓后开放 PDA 清点。 */
@PostMapping("/{tripNo}/tasks/{customerId}/{transferNo}/arrive")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<List<FleetPickupModels.Task>> arrive(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,
@PathVariable String customerId,@PathVariable String transferNo,@Valid @RequestBody FleetPickupModels.Command request) {
return ApiResponse.success(service.arrive(tripNo,customerId,transferNo,operator(user),request));
}
/** 全车送仓后仅结案本次取货运输。 */
@PostMapping("/{tripNo}/complete")
@PreAuthorize("hasAuthority('platform:fleet:trip:complete')")
public ApiResponse<FleetTripModels.Detail> complete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid @RequestBody FleetPickupModels.Command request) {
return ApiResponse.success(service.complete(tripNo,operator(user),request));
}
/** 平台调度入口不接受客户会话借同名权限越权。 */
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();
}
}
@@ -0,0 +1,55 @@
package com.baiyue.lastleg.fleet.pickup;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.security.OverseasPermissions;
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.server.ResponseStatusException;
/** 原转运单详情读取运输投影,客户 ID 始终从认证上下文取得。 */
@RestController
public class TransferPickupProgressController {
private final FleetPickupService service;
/** 注入只读进度能力。 */
public TransferPickupProgressController(FleetPickupService service) { this.service=service; }
/** 客户只能读取本企业订单的取货进度。 */
@GetMapping("/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/pickup-progress")
@PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_TRANSFER_LIST+"')")
public ApiResponse<FleetPickupModels.Task> merchant(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String transferNo) {
return ApiResponse.success(service.progress(customer(user),transferNo));
}
/** 平台详情以明确客户和业务号读取,不暴露整车其他货物。 */
@GetMapping("/api/platform/v1/last-leg/warehouse-transfers/{transferNo}/pickup-progress")
@PreAuthorize("hasAuthority('"+OverseasPermissions.LAST_MILE_ORDER_LIST+"')")
public ApiResponse<FleetPickupModels.Task> platform(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String transferNo,@RequestParam String customerId) {
platform(user);return ApiResponse.success(service.progress(customerId,transferNo));
}
/** 客户凭证只允许读取当前票确认过的引用。 */
@GetMapping("/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/pickup-evidence/{fileId}")
@PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_TRANSFER_LIST+"')")
public ResponseEntity<byte[]> merchantEvidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String transferNo,@PathVariable long fileId) {
return download(customer(user),transferNo,fileId);
}
/** 平台调度或尾程详情沿本票授权下载凭证。 */
@GetMapping("/api/platform/v1/last-leg/warehouse-transfers/{transferNo}/pickup-evidence/{fileId}")
@PreAuthorize("hasAnyAuthority('platform:fleet:trip:list','"+OverseasPermissions.LAST_MILE_ORDER_LIST+"')")
public ResponseEntity<byte[]> platformEvidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String transferNo,@PathVariable long fileId,@RequestParam String customerId) {
platform(user);return download(customerId,transferNo,fileId);
}
/** 文件不进入浏览器公共缓存,文件名由调用方已授权元数据提供。 */
private ResponseEntity<byte[]> download(String customer,String number,long file) {
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.APPLICATION_OCTET_STREAM)
.header("X-Content-Type-Options","nosniff").body(service.evidence(customer,number,file));
}
/** 认证客户范围禁止从请求参数取得。 */
private static String customer(AuthenticatedUser user) {
if(user==null || user.tenantId()==null || user.tenantId()<=0)throw new ResponseStatusException(HttpStatus.FORBIDDEN,"请使用客户账号");return user.tenantId().toString();
}
/** 平台读取必须验证零作用域和应用身份。 */
private static void platform(AuthenticatedUser user) {
if(user==null || !Long.valueOf(0).equals(user.tenantId()) || !"PLATFORM".equals(user.appCode()))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可以查询");
}
}
@@ -284,7 +284,8 @@ public class FleetBookingService {
private FleetBookingModels.Result result(FleetTripEntity trip,FleetBookingEntity row,boolean replayed){return new FleetBookingModels.Result(trip.getRecordVersion(),reservations.response(row),replayed);}
private FleetTripEntity trip(String number,boolean lock){var value=lock?trips.lockByNo(number):trips.findByNo(number);if(value==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"车次不存在");return value;}
private FleetBookingEntity booking(FleetTripEntity trip,String number){var row=bookings.lock(trip.getId(),number);if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"当前车次无此外接预订");return row;}
private static void requireTrip(FleetTripEntity trip,long version){if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次允许管理外接预订");if(trip.getRecordVersion()!=version)throw conflict("车次已被修改,请刷新");}
/** 外接预订只服务出库派送车次,并校验当前版本。 */
private static void requireTrip(FleetTripEntity trip,long version){if("PICKUP".equals(trip.getTripPurpose()))throw conflict("取货车次不能承接出库拼车预订");if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次允许管理外接预订");if(trip.getRecordVersion()!=version)throw conflict("车次已被修改,请刷新");}
private static void requireVersion(FleetBookingEntity row,long version){if(row.getRecordVersion()!=version)throw conflict("外接预订已被修改,请刷新");}
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception error){throw new IllegalStateException(error);}}
private String hash(Object value){try{ObjectNode node=json.valueToTree(value);node.remove("idempotencyKey");node.remove("tripRecordVersion");return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(node).getBytes(StandardCharsets.UTF_8)));}catch(Exception error){throw new IllegalStateException(error);}}
@@ -295,9 +295,12 @@ public class FleetCapacityOfferService {
}
static TripCapacityPolicy.Load min(TripCapacityPolicy.Load a,TripCapacityPolicy.Load b){return new TripCapacityPolicy.Load(Math.min(a.palletSpaces(),b.palletSpaces()),a.weightKg().min(b.weightKg()),a.volumeCbm().min(b.volumeCbm()));}
private static boolean positive(TripCapacityPolicy.Load value){return value!=null&&value.palletSpaces()>0&&value.weightKg().signum()>0&&value.volumeCbm().signum()>0;}
static boolean cutoffValid(FleetTripEntity trip,LocalDateTime cutoff,Instant now){return "PLANNED".equals(trip.getStatus())&&cutoff!=null&&trip.getPlannedDepartureAt()!=null&&now.isBefore(instant(cutoff))&&!cutoff.isAfter(trip.getPlannedDepartureAt());}
/** 余位交货截止只适用于尚未发车的派送车次,取货车不接受出库拼车。 */
static boolean cutoffValid(FleetTripEntity trip,LocalDateTime cutoff,Instant now){return !"PICKUP".equals(trip.getTripPurpose())&&"PLANNED".equals(trip.getStatus())&&cutoff!=null&&trip.getPlannedDepartureAt()!=null&&now.isBefore(instant(cutoff))&&!cutoff.isAfter(trip.getPlannedDepartureAt());}
/** 返回不可接单的具体原因,防止使用已有发布绕过用途及时间门禁。 */
static String openBlock(FleetCapacityOfferEntity row,FleetTripEntity trip,Instant now){
if(!"OPEN".equals(row.getStatus()))return "发布未开放接单";
if("PICKUP".equals(trip.getTripPurpose()))return "取货车次不发布出库拼车余位";
if(!"PLANNED".equals(trip.getStatus()))return "车次已发车、结案或取消,不再接受新意向";
if(!cutoffValid(trip,row.getCutoffAt(),now))return "交货截止已过或不符合当前发车时间";return null;
}
@@ -5,6 +5,11 @@ import java.util.List;
@Mapper
public interface FleetResourceMapper {
/** 一次读取当前候选的实际占用,不按车次计划状态推断空闲。 */
@Select({"<script>SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment",
"WHERE resource_kind=#{kind} AND resource_id IN",
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>"})
List<Assignment> candidates(@Param("kind") String kind, @Param("ids") List<Long> ids);
@Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE resource_kind=#{kind} AND resource_id=#{id}")
Assignment find(@Param("kind") String kind, @Param("id") long id);
@Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE trip_id=#{tripId} ORDER BY resource_kind,resource_id")
@@ -15,6 +15,8 @@ public class FleetTripEntity {
private String idempotencyKey;
private String requestHash;
private String status;
/** 区分出库派送与上门取货入仓。 */
private String tripPurpose = "DELIVERY";
private Long warehouseId;
private String warehouseCode;
private String warehouseName;
@@ -327,7 +327,9 @@ public class FleetTripExecutionService {
private static void requireWarehouse(FleetTripEntity trip, String warehouseId) {
if (!trip.getWarehouseId().toString().equals(warehouseId)) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "当前绑定仓无此车次");
}
/** 出库装载只能用于派送车次,取货必须沿专用运输链执行。 */
private static void requireVersionAndPlan(FleetTripEntity trip, long version) {
if ("PICKUP".equals(trip.getTripPurpose())) throw conflict("取货车次请使用取货任务操作");
if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重新确认");
if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作");
}
@@ -8,6 +8,11 @@ import java.time.Instant;
import java.util.List;
public final class FleetTripModels {
/** 分配候选只查询本次下拉框的资源,限制批量大小。 */
public record ResourceQuery(@NotBlank @Pattern(regexp="VEHICLE|DRIVER") String kind,
@NotEmpty @Size(max=100) List<@NotNull @Positive Long> resourceIds) { }
/** 可用仅表示未被别车占用,证照、区域和容量仍由分配命令复验。 */
public record ResourceOccupancy(String resourceId, String occupiedTripNo, boolean available) { }
private FleetTripModels() { }
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, @Size(max = 128) String keyword,
String status, @Positive Long warehouseId) {
@@ -41,7 +46,7 @@ public final class FleetTripModels {
Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm,
Instant plannedDepartureAt, Instant expectedDriverEndAt, Instant departedAt, Instant completedAt,
List<Stop> stops, String remark, long recordVersion, Instant updatedAt,
FleetCompletionModels.ReleaseInfo vehicleRelease, FleetCompletionModels.ReleaseInfo driverRelease) { }
FleetCompletionModels.ReleaseInfo vehicleRelease, FleetCompletionModels.ReleaseInfo driverRelease, String tripPurpose) { }
public record Detail(Response trip, DriverQualificationPolicy.Qualification driverQualification, List<DispatchBinding> dispatches, ReviewState loadingReview,
List<PreviousDispatch> previousDispatches,String appointmentHistoryUnavailableReason) { }
public record AuditSnapshot(Response trip, List<DispatchBinding> dispatches, FleetExecutionModels.ConfirmedLoad confirmedLoad,
@@ -0,0 +1,59 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 车次主流程的数据访问集中经过 Repository,事务和状态决策留在 Service。 */
@Repository
public class FleetTripRepository {
private final FleetTripMapper trips;
private final FleetTripDispatchMapper bindings;
private final FleetTripHistoryMapper histories;
private final FleetResourceMapper resources;
/** 注入车次、成员、历史及资源 Mapper。 */
public FleetTripRepository(FleetTripMapper trips,FleetResourceMapper resources,FleetTripHistoryMapper histories,FleetTripDispatchMapper bindings) {
this.trips=trips;this.resources=resources;this.histories=histories;this.bindings=bindings;
}
/** 按筛选分页查询车次。 */
public Page<FleetTripEntity> pageTrips(Page<FleetTripEntity> page,Wrapper<FleetTripEntity> filter) { return trips.selectPage(page,filter); }
/** 查找原创建请求。 */
public FleetTripEntity findCreated(String operator,String key) { return trips.findCreated(operator,key); }
/** 锁定原创建请求处理唯一键并发。 */
public FleetTripEntity lockCreated(String operator,String key) { return trips.lockCreated(operator,key); }
/** 新增车次记录。 */
public int insertTrip(FleetTripEntity row) { return trips.insert(row); }
/** 按业务号查询车次。 */
public FleetTripEntity findTrip(String number) { return trips.findByNo(number); }
/** 按业务号加锁读取车次。 */
public FleetTripEntity lockTrip(String number) { return trips.lockByNo(number); }
/** 按版本条件原子保存车次。 */
public int updateTrip(FleetTripEntity row,Wrapper<FleetTripEntity> filter) { return trips.update(row,filter); }
/** 读取车次当前排车成员。 */
public List<FleetTripDispatch> listBindings(long tripId) { return bindings.listByTrip(tripId); }
/** 锁定单据当前关联。 */
public FleetTripDispatch lockBinding(long id) { return bindings.lockByDispatch(id); }
/** 保存成员审核。 */
public int updateBinding(FleetTripDispatch row) { return bindings.updateById(row); }
/** 新增车次成员。 */
public int insertBinding(FleetTripDispatch row) { return bindings.insert(row); }
/** 删除尚未执行的成员关联。 */
public int deleteBinding(long id) { return bindings.deleteById(id); }
/** 使当前车次成员的旧审核失效。 */
public int updateBindings(Wrapper<FleetTripDispatch> filter) { return bindings.update(null,filter); }
/** 分页读取不可变车次历史。 */
public Page<FleetTripHistory> pageHistory(Page<FleetTripHistory> page,Wrapper<FleetTripHistory> filter) { return histories.selectPage(page,filter); }
/** 读取原成员绑定历史。 */
public List<FleetTripHistory> bindingHistory(long id) { return histories.bindingHistory(id); }
/** 保存当前操作快照。 */
public int insertHistory(FleetTripHistory row) { return histories.insert(row); }
/** 查询资源实时占用。 */
public FleetResourceMapper.Assignment resource(String kind,long id) { return resources.find(kind,id); }
/** 批量读取候选资源占用,避免列表逐项查询。 */
public List<FleetResourceMapper.Assignment> resourceOccupancy(String kind,List<Long> ids) { return resources.candidates(kind,ids); }
/** 唯一键防止车辆或司机同时承接两趟任务。 */
public int reserve(String kind,long id,long tripId,String number) { return resources.reserve(kind,id,tripId,number); }
/** 仅释放属于原车次的占用。 */
public int release(String kind,long id,long tripId) { return resources.release(kind,id,tripId); }
}
@@ -29,10 +29,8 @@ import java.util.*;
public class FleetTripService {
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workChanges;
@jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations;
private final FleetTripMapper trips;
private final FleetResourceMapper resources;
private final FleetTripHistoryMapper histories;
private final FleetTripDispatchMapper bindings;
private final FleetTripRepository repository;
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups;
private final FleetDispatchGateway dispatches;
private final VehicleRepository vehicles;
private final DriverService drivers;
@@ -41,20 +39,23 @@ public class FleetTripService {
private final Clock clock;
private final FleetBookingReservations bookingReservations;
/** 注入车次持久化、资源资格和跨域业务协作依赖。 */
@Autowired
public FleetTripService(FleetTripMapper trips, FleetResourceMapper resources, FleetTripHistoryMapper histories,
public FleetTripService(FleetTripRepository repository,
VehicleRepository vehicles, DriverService drivers, FleetWarehouseDirectory warehouses, ObjectMapper json,
FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations) {
this(trips, resources, histories, vehicles, drivers, warehouses, json, bindings, dispatches, bookingReservations, Clock.systemUTC());
FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations) {
this(repository, vehicles, drivers, warehouses, json, dispatches, bookingReservations, Clock.systemUTC());
}
FleetTripService(FleetTripMapper trips, FleetResourceMapper resources, FleetTripHistoryMapper histories,
/** 注入可控时钟,供车次规则和并发持久化验收统一时间。 */
FleetTripService(FleetTripRepository repository,
VehicleRepository vehicles, DriverService drivers, FleetWarehouseDirectory warehouses, ObjectMapper json,
FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations, Clock clock) {
this.trips = trips; this.resources = resources; this.histories = histories; this.vehicles = vehicles;
FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations, Clock clock) {
this.repository = repository; this.vehicles = vehicles;
this.drivers = drivers; this.warehouses = warehouses; this.json = json; this.clock = clock;
this.bindings = bindings; this.dispatches = dispatches; this.bookingReservations = bookingReservations;
this.dispatches = dispatches; this.bookingReservations = bookingReservations;
}
/** 按平台筛选条件分页查询车次并补齐仓库与资源信息。 */
public PageResult<FleetTripModels.Response> page(FleetTripModels.Query query) {
if (query.status() != null && !query.status().isBlank() && !Set.of("PLANNED", "IN_TRANSIT", "COMPLETED", "CANCELLED").contains(query.status()))
throw bad("车次状态无效");
@@ -64,20 +65,50 @@ public class FleetTripService {
if (query.keyword() != null && !query.keyword().isBlank()) filter.and(part -> part.like(FleetTripEntity::getTripNo, query.keyword().trim())
.or().like(FleetTripEntity::getVehiclePlate, query.keyword().trim()).or().like(FleetTripEntity::getDriverName, query.keyword().trim()));
filter.orderByDesc(FleetTripEntity::getUpdatedAt, FleetTripEntity::getId);
var page = trips.selectPage(new Page<>(query.page(), query.size()), filter);
var page = repository.pageTrips(new Page<>(query.page(), query.size()), filter);
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
/** 按全局车次号读取计划、关联单据及审核状态。 */
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public FleetTripModels.Detail detail(String tripNo) {
FleetTripEntity trip = required(tripNo);
return detail(trip);
}
/** 下拉候选批量展示实际占用,本车资源仍可选;分配时必须再次锁定复验。 */
@Transactional(readOnly = true)
public List<FleetTripModels.ResourceOccupancy> resourceOccupancy(String tripNo,FleetTripModels.ResourceQuery query) {
var trip=repository.findTrip(tripNo);
if(trip==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"车次不存在");
if(query.kind()==null || !Set.of("VEHICLE","DRIVER").contains(query.kind()) || query.resourceIds()==null
|| query.resourceIds().isEmpty() || query.resourceIds().size()>100
|| query.resourceIds().stream().anyMatch(id->id==null || id<=0))throw bad("请选择有效的车辆或司机候选");
var ids=query.resourceIds().stream().distinct().toList();
var occupied=new HashMap<Long,FleetResourceMapper.Assignment>();
for(var row:repository.resourceOccupancy(query.kind(),ids))occupied.put(row.resourceId(),row);
return ids.stream().map(id->{
var row=occupied.get(id);
return new FleetTripModels.ResourceOccupancy(id.toString(),row==null?null:row.tripNo(),row==null || row.tripId()==trip.getId());
}).toList();
}
/** 创建派送用途车次,保留原有请求幂等语义。 */
@Transactional
public FleetTripModels.Detail create(String operator, FleetTripModels.Create request) {
String hash = hash(write(request));
var existing = trips.findCreated(operator, request.idempotencyKey());
return createPlan(operator, request, "DELIVERY");
}
/** 取货车次首次历史即标识取货用途,复用统一的资源和计划规则。 */
@Transactional
public FleetTripModels.Detail createPickup(String operator, FleetTripModels.Create request) {
return createPlan(operator, request, "PICKUP");
}
/** 两类车次共用建档逻辑,幂等摘要包含用途以防串用。 */
private FleetTripModels.Detail createPlan(String operator, FleetTripModels.Create request, String purpose) {
String hash = hash(("PICKUP".equals(purpose) ? "PICKUP:" : "") + write(request));
var existing = repository.findCreated(operator, request.idempotencyKey());
if (existing != null) return replay(existing, hash);
validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops());
var warehouse = warehouses.required(request.warehouseId(), "OVERSEAS");
@@ -85,15 +116,15 @@ public class FleetTripService {
trip.setId(IdWorker.getId());
trip.setTripNo("TR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT));
trip.setIdempotencyKey(request.idempotencyKey()); trip.setRequestHash(hash);
trip.setStatus("PLANNED"); trip.setWarehouseId(Long.valueOf(warehouse.id()));
trip.setTripPurpose(purpose); trip.setStatus("PLANNED"); trip.setWarehouseId(Long.valueOf(warehouse.id()));
trip.setWarehouseCode(warehouse.code()); trip.setWarehouseName(warehouse.name());
trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt()));
trip.setStopsJson(write(request.stops())); trip.setRemark(trim(request.remark()));
trip.setRecordVersion(0L); trip.setCreatedBy(operator); trip.setUpdatedBy(operator);
trip.setCreatedAt(now()); trip.setUpdatedAt(trip.getCreatedAt());
try { if (trips.insert(trip) != 1) throw conflict("车次创建失败"); }
try { if (repository.insertTrip(trip) != 1) throw conflict("车次创建失败"); }
catch (DuplicateKeyException duplicate) {
var concurrent = trips.lockCreated(operator, request.idempotencyKey());
var concurrent = repository.lockCreated(operator, request.idempotencyKey());
if (concurrent == null) throw conflict("车次编号冲突,请重试");
return replay(concurrent, hash);
}
@@ -101,11 +132,12 @@ public class FleetTripService {
return detail(trip);
}
/** 锁定车次及资源,复核司机证照和车辆容量后原子改派。 */
@Transactional
public FleetTripModels.Detail assign(String tripNo, String operator, FleetTripModels.Assign request) {
FleetTripEntity trip = planned(tripNo, request.recordVersion());
requireReason(request.reason());
// All assigners serialize first on the vehicle, then on the driver. Resource uniqueness is the final safeguard.
// 分配始终先锁车辆再锁司机,唯一占用记录作为并发分配的最终约束。
var vehicle = vehicles.lockById(request.vehicleId()).orElseThrow(() -> bad("车辆档案不存在"));
if (!"OVERSEAS".equals(vehicle.region()) || !"IN_SERVICE".equals(vehicle.status())) throw bad("请选择在役海外车辆");
if (vehicle.maxPalletSpaces() == null || vehicle.maxLoadKg() == null || vehicle.maxVolumeCbm() == null
@@ -129,7 +161,7 @@ public class FleetTripService {
trip.setVehicleId(vehicle.id()); trip.setVehiclePlate(vehicle.plateNumber());
trip.setMaxPalletSpaces(vehicle.maxPalletSpaces()); trip.setMaxLoadKg(vehicle.maxLoadKg()); trip.setMaxVolumeCbm(vehicle.maxVolumeCbm());
trip.setDriverId(Long.valueOf(driver.id())); trip.setDriverName(driver.details().name()); trip.setDriverPhone(driver.details().phone());
for (var binding : bindings.listByTrip(trip.getId())) {
for (var binding : repository.listBindings(trip.getId())) {
var dispatch = dispatches.lockForAssignment(binding.getDispatchNo());
if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw conflict("排车单与车次起运仓不一致");
dispatches.assign(dispatch, assignment(trip));
@@ -140,6 +172,7 @@ public class FleetTripService {
return new FleetTripModels.Detail(response(trip), driver.qualification(), currentBindings, FleetTripReview.read(json, trip.getLoadingReviewJson()),history.dispatches(),history.unavailableReason());
}
/** 修改未发车计划;取货车次保留原单取货点和目的仓。 */
@Transactional
public FleetTripModels.Detail updatePlan(String tripNo, String operator, FleetTripModels.Plan request) {
FleetTripEntity trip = planned(tripNo, request.recordVersion());
@@ -148,9 +181,10 @@ 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("取货路线由原单地址和入仓仓库确定,不能修改为出库路线");
boolean unchangedPlan = unchangedRoute && trip.getPlannedDepartureAt().equals(utc(request.plannedDepartureAt()))
&& trip.getExpectedDriverEndAt().equals(utc(request.expectedDriverEndAt()));
for (var binding : bindings.listByTrip(trip.getId())) {
for (var binding : repository.listBindings(trip.getId())) {
if (unchangedRoute) dispatches.lockForAssignment(binding.getDispatchNo());
else dispatches.lockForPlanning(binding.getDispatchNo());
}
@@ -160,6 +194,7 @@ public class FleetTripService {
return detail(trip);
}
/** 逐张审核客户排车单,不以同车其他客户审核代替本单。 */
@Transactional
public FleetTripModels.Detail reviewDispatch(String tripNo, String dispatchNo, String operator, FleetTripModels.Review request) {
var trip = planned(tripNo, request.recordVersion());
@@ -172,20 +207,29 @@ public class FleetTripService {
if (!"OVERSEAS".equals(driver.details().region())) throw conflict("请选择海外司机");
}
var dispatch = dispatches.lockForAssignment(dispatchNo);
var binding = bindings.lockByDispatch(dispatch.dispatchId());
var binding = repository.lockBinding(dispatch.dispatchId());
if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次");
binding.setReviewJson(write(new FleetTripModels.ReviewState(request.decision(), reason, operator, clock.instant(), request.recordVersion())));
if (bindings.updateById(binding) != 1) throw conflict("排车单审核保存失败");
if (repository.updateBinding(binding) != 1) throw conflict("排车单审核保存失败");
trip.setLoadingReviewJson(null);
persist(trip, request.recordVersion(), "DISPATCH_REVIEW", reason, operator);
return detail(trip);
}
/** 取消未执行车次,释放资源并将取货原单退回待安排。 */
@Transactional
public FleetTripModels.Detail cancel(String tripNo, String operator, FleetTripModels.Cancel request) {
FleetTripEntity trip = planned(tripNo, request.recordVersion());
requireReason(request.reason());
if (!bindings.listByTrip(trip.getId()).isEmpty()) throw conflict("请先解除客户排车单关联,再取消车次");
if ("PICKUP".equals(trip.getTripPurpose())) {
// 取消车次与释放原单关联同事务,失败时全部回滚。
for (var task : pickups.tasks(tripNo)) {
if (!"ASSIGNED".equals(task.getStatus())) throw conflict("已开始取货,不能取消车次");
pickups.history(task,"UNASSIGN",operator,request.reason());
}
pickups.unassign(tripNo);
}
if (!repository.listBindings(trip.getId()).isEmpty()) throw conflict("请先解除客户排车单关联,再取消车次");
if (bookingReservations.hasConfirmed(trip.getId())) throw conflict("请先取消已确认的外接预订,再取消车次");
if (trip.getVehicleId() != null) release("VEHICLE", trip.getVehicleId(), trip.getId());
if (trip.getDriverId() != null) release("DRIVER", trip.getDriverId(), trip.getId());
@@ -195,29 +239,32 @@ public class FleetTripService {
}
@jakarta.annotation.Resource private FleetDispatchPlanningGateway planning;
/** 读取当前车次可关联的客户排车候选。 */
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public com.baiyue.common.core.api.PageResult<FleetDispatchPlanningGateway.Candidate> dispatchCandidates(String tripNo,String keyword,int page,int size) {
var trip=trips.findByNo(tripNo);
var trip=repository.findTrip(tripNo);
if(trip==null)throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND,"车次不存在");
if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次可以添加待运货物");
return planning.candidates(tripNo,trip.getWarehouseId().toString(),keyword,page,size);
}
/** 关联同仓客户排车单并同步车辆司机,禁止取货车次混用。 */
@Transactional
public FleetTripModels.Detail bindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) {
var trip = planned(tripNo, request.recordVersion());
if ("PICKUP".equals(trip.getTripPurpose())) throw conflict("取货车次不能关联出库排车单");
requireReason(request.reason());
if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机");
var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim());
appointmentAllocations.requireTripBinding(dispatch.dispatchNo(),trip.getId());
if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw bad("排车单与车次必须使用同一起运仓");
var bound = bindings.lockByDispatch(dispatch.dispatchId());
var bound = repository.lockBinding(dispatch.dispatchId());
if (bound != null) throw conflict("排车单已关联车次 " + bound.getTripNo());
var binding = new FleetTripDispatch();
binding.setDispatchId(dispatch.dispatchId()); binding.setDispatchNo(dispatch.dispatchNo()); binding.setCustomerId(dispatch.customerId());
binding.setTripId(trip.getId()); binding.setTripNo(tripNo); binding.setPackageCount(dispatch.packageCount());
binding.setBoundBy(operator); binding.setBoundAt(now());
try { if (bindings.insert(binding) != 1) throw conflict("排车单关联失败"); }
try { if (repository.insertBinding(binding) != 1) throw conflict("排车单关联失败"); }
catch (DuplicateKeyException duplicate) { throw conflict("排车单已关联其他车次,请刷新后重试"); }
bookingReservations.bindIncoming(trip, dispatch, dispatches.sourceCargo(dispatch.dispatchNo()), operator);
var route = matchedStops(response(trip).stops(), dispatches.manifest(dispatch.dispatchNo(), false).cargo());
@@ -250,74 +297,85 @@ public class FleetTripService {
return List.copyOf(result);
}
/** 解除尚未执行的排车关联并使原装载审核失效。 */
@Transactional
public FleetTripModels.Detail unbindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) {
var trip = planned(tripNo, request.recordVersion());
requireReason(request.reason());
var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim());
var binding = bindings.lockByDispatch(dispatch.dispatchId());
var binding = repository.lockBinding(dispatch.dispatchId());
if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次");
dispatches.assign(dispatch, new FleetDispatchGateway.Assignment(null, null, null));
if (bindings.deleteById(binding.getDispatchId()) != 1) throw conflict("排车单关联已变化,请刷新后重试");
if (repository.deleteBinding(binding.getDispatchId()) != 1) throw conflict("排车单关联已变化,请刷新后重试");
bookingReservations.unbindIncoming(trip, dispatch.dispatchNo(), operator);
persist(trip, request.recordVersion(), "UNBIND_DISPATCH", request.reason(), operator);
return detail(trip);
}
/** 分页读取不可变车次历史,用于追溯计划和执行变更。 */
public PageResult<FleetTripModels.History> history(String tripNo, int page, int size) {
var trip = required(tripNo);
var result = histories.selectPage(new Page<>(page, size), new LambdaQueryWrapper<FleetTripHistory>()
var result = repository.pageHistory(new Page<>(page, size), new LambdaQueryWrapper<FleetTripHistory>()
.eq(FleetTripHistory::getTripId, trip.getId()).orderByDesc(FleetTripHistory::getRecordVersion));
return new PageResult<>(result.getRecords().stream().map(event -> new FleetTripModels.History(event.getRecordVersion(), event.getAction(),
event.getReason(), event.getOperatorId(), instant(event.getOccurredAt()), read(event.getSnapshotJson(), FleetTripModels.AuditSnapshot.class))).toList(),
result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
}
/** 在唯一资源占用记录内锁定车辆或司机,阻止并发重复分配。 */
private void reserve(String kind, long resourceId, FleetTripEntity trip) {
var current = resources.find(kind, resourceId);
var current = repository.resource(kind, resourceId);
if (current != null) {
if (current.tripId() == trip.getId()) return;
throw conflict(("VEHICLE".equals(kind) ? "车辆" : "司机") + "已被车次 " + current.tripNo() + " 占用");
}
try { if (resources.reserve(kind, resourceId, trip.getId(), trip.getTripNo()) != 1) throw conflict("车次资源预留失败"); }
try { if (repository.reserve(kind, resourceId, trip.getId(), trip.getTripNo()) != 1) throw conflict("车次资源预留失败"); }
catch (DuplicateKeyException duplicate) { throw conflict("车辆或司机已被其他车次占用,请刷新后重试"); }
}
/** 仅释放属于本车次的资源占用。 */
private void release(String kind, long resourceId, long tripId) {
if (resources.release(kind, resourceId, tripId) != 1) throw conflict("车次资源占用不一致,操作未完成");
if (repository.release(kind, resourceId, tripId) != 1) throw conflict("车次资源占用不一致,操作未完成");
}
/** 锁定读取车次,不存在时返回明确的业务错误。 */
private FleetTripEntity required(String number) {
var trip = trips.findByNo(number);
var trip = repository.findTrip(number);
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
return trip;
}
/** 校验车次版本及未发车状态,防止陈旧页面覆盖新安排。 */
private FleetTripEntity planned(String number, long version) {
var trip = trips.lockByNo(number);
var trip = repository.lockTrip(number);
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重试");
if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作");
return trip;
}
/** 复核创建请求摘要,同键不同内容不允许复用。 */
private FleetTripModels.Detail replay(FleetTripEntity trip, String hash) {
if (!Objects.equals(trip.getRequestHash(), hash)) throw conflict("创建请求标识已用于不同车次内容");
return detail(trip);
}
/** 组合车次、排车关联与预约历史,形成一致的详情快照。 */
private FleetTripModels.Detail detail(FleetTripEntity trip) {
var currentBindings=bindingResponses(trip);
var history=previousDispatches(trip,currentBindings);
return new FleetTripModels.Detail(response(trip), trip.getDriverId() == null ? null
: drivers.detail(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())).qualification(), currentBindings, FleetTripReview.read(json, trip.getLoadingReviewJson()),history.dispatches(),history.unavailableReason());
}
/** 将客户隔离的排车关联转换为详情展示模型。 */
private List<FleetTripModels.DispatchBinding> bindingResponses(FleetTripEntity trip) {
var current=bindings.listByTrip(trip.getId());
var current=repository.listBindings(trip.getId());
var references=appointmentAllocations.references(current.stream().map(FleetTripDispatch::getDispatchId).toList());
return current.stream().map(binding -> new FleetTripModels.DispatchBinding(binding.getDispatchNo(),
binding.getCustomerId(), binding.getPackageCount(), binding.getBoundBy(), instant(binding.getBoundAt()), FleetTripReview.read(json, binding.getReviewJson()),references.getOrDefault(binding.getDispatchId(),List.of()))).toList();
}
/** 组装当前车次关联单据和预约追溯信息。 */
private record AppointmentHistory(List<FleetTripModels.PreviousDispatch> dispatches,String unavailableReason) { }
/** 读取预约工作批次的历史排车引用,保留历史不可用原因。 */
private AppointmentHistory previousDispatches(FleetTripEntity trip,List<FleetTripModels.DispatchBinding> current) {
var history=histories.bindingHistory(trip.getId());
var history=repository.bindingHistory(trip.getId());
if(history.size()>1000) return new AppointmentHistory(List.of(),"原车绑定历史超过完整读取上限,请分页核对车次历史");
String unavailableReason=null;
var seen=new HashSet<List<String>>();
@@ -338,13 +396,16 @@ public class FleetTripService {
}
return new AppointmentHistory(List.copyOf(previous),unavailableReason);
}
/** 构造稳定预约批次关联键,避免将同名记录误合并。 */
private static List<String> referenceKey(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference batch,
com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchSource source) {
return List.of(batch.batchKey(),batch.customerId(),source.sourceKey(),source.destinationKey());
}
/** 生成跨域同步所需的车辆司机分配快照。 */
private static FleetDispatchGateway.Assignment assignment(FleetTripEntity trip) {
return new FleetDispatchGateway.Assignment(trip.getVehiclePlate(), trip.getDriverName(), trip.getDriverPhone());
}
/** 组装车次计划、用途、资源及释放记录的对外响应。 */
private FleetTripModels.Response response(FleetTripEntity trip) {
List<FleetTripModels.Stop> stops;
try { stops = json.readValue(trip.getStopsJson(), new TypeReference<>() { }); }
@@ -353,8 +414,9 @@ public class FleetTripService {
text(trip.getVehicleId()), trip.getVehiclePlate(), text(trip.getDriverId()), trip.getDriverName(), trip.getDriverPhone(),
trip.getMaxPalletSpaces(), trip.getMaxLoadKg(), trip.getMaxVolumeCbm(), instant(trip.getPlannedDepartureAt()), instant(trip.getExpectedDriverEndAt()),
instant(trip.getDepartedAt()), instant(trip.getCompletedAt()), List.copyOf(stops), trip.getRemark(), trip.getRecordVersion(), instant(trip.getUpdatedAt()),
releaseInfo(trip.getVehicleReleaseJson()), releaseInfo(trip.getDriverReleaseJson()));
releaseInfo(trip.getVehicleReleaseJson()), releaseInfo(trip.getDriverReleaseJson()), trip.getTripPurpose());
}
/** 校验发车和任务结束时间以及路线站点完整性。 */
private void validatePlan(Instant departure, Instant end, List<FleetTripModels.Stop> stops) {
if (departure == null || end == null || !end.isAfter(departure) || !end.isAfter(clock.instant())) throw bad("司机预计任务结束时间须晚于计划发车及当前时间");
try {
@@ -363,17 +425,19 @@ public class FleetTripService {
if (stops == null || stops.size() < 2 || stops.size() > 200 || stops.stream().anyMatch(stop -> stop == null || trim(stop.name()) == null))
throw bad("车次须包含起点和目的地,最多200个站点");
}
/** 按乐观版本保存车次变更并记录不可变操作快照。 */
private void persist(FleetTripEntity trip, long version, String action, String reason, String operator) {
if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setPlannedLoadJson(null);
if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setLoadJson(null);
if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL", "CONFIRM_LOAD").contains(action)) trip.setLoadingReviewJson(null);
if (Set.of("ASSIGN", "PLAN").contains(action)) bindings.update(null, new LambdaUpdateWrapper<FleetTripDispatch>()
if (Set.of("ASSIGN", "PLAN").contains(action)) repository.updateBindings(new LambdaUpdateWrapper<FleetTripDispatch>()
.eq(FleetTripDispatch::getTripId, trip.getId()).set(FleetTripDispatch::getReviewJson, null));
trip.setRecordVersion(version + 1); trip.setUpdatedAt(now()); trip.setUpdatedBy(operator);
if (trips.update(trip, new LambdaUpdateWrapper<FleetTripEntity>().eq(FleetTripEntity::getId, trip.getId()).eq(FleetTripEntity::getRecordVersion, version)) != 1)
if (repository.updateTrip(trip, new LambdaUpdateWrapper<FleetTripEntity>().eq(FleetTripEntity::getId, trip.getId()).eq(FleetTripEntity::getRecordVersion, version)) != 1)
throw conflict("车次已被修改,请刷新后重试");
record(trip, action, reason, operator);
}
/** 为当前版本保存操作人、原因和完整车次历史。 */
private void record(FleetTripEntity trip, String action, String reason, String operator) {
workChanges.changed(trip);
var event = new FleetTripHistory(); event.setId(IdWorker.getId()); event.setTripId(trip.getId()); event.setRecordVersion(trip.getRecordVersion());
@@ -382,26 +446,39 @@ public class FleetTripService {
: read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class), trip.getCompletionJson() == null ? null
: read(trip.getCompletionJson(), FleetCompletionModels.Completed.class), FleetTripReview.read(json, trip.getLoadingReviewJson()),
trip.getPlannedLoadJson() == null ? null : read(trip.getPlannedLoadJson(), FleetExecutionModels.PlannedLoad.class), bookingReservations.responses(trip.getId()))));
if (histories.insert(event) != 1) throw conflict("车次历史保存失败");
if (repository.insertHistory(event) != 1) throw conflict("车次历史保存失败");
}
/** 将车次快照序列化为持久化 JSON。 */
private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException("车次序列化失败", e); } }
/** 解析可空的资源任务结束记录。 */
private FleetCompletionModels.ReleaseInfo releaseInfo(String value) {
if (value == null) return null;
var released = read(value, FleetCompletionModels.Released.class);
return new FleetCompletionModels.ReleaseInfo(released.actualEndAt(), released.endLocation(), released.vehicleStatus());
}
/** 在调用方已有事务内保存执行事实及对应历史。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void recordExecution(FleetTripEntity trip, long version, String action, String reason, String operator) {
persist(trip, version, action, reason, operator);
}
/** 按指定模型解析历史 JSON,损坏记录明确报错。 */
private <T> T read(String value, Class<T> type) { try { return json.readValue(value, type); } catch (JsonProcessingException 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 (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } }
/** 使用统一时钟获取 UTC 业务时间。 */
private LocalDateTime now() { return utc(clock.instant()); }
/** 将统一时刻转换为数据库 UTC 时间。 */
private static LocalDateTime utc(Instant value) { return LocalDateTime.ofInstant(value, ZoneOffset.UTC); }
/** 将可空的数据库时间转换为统一时刻。 */
private static Instant instant(LocalDateTime value) { return value == null ? null : value.toInstant(ZoneOffset.UTC); }
/** 将可空的长整型标识转换为字符串,避免前端精度丢失。 */
private static String text(Long value) { return value == null ? null : value.toString(); }
/** 将空白备注归一为空值。 */
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
/** 关键变更必须填写操作原因,便于审计追溯。 */
private static void requireReason(String reason) { if (trim(reason) == null) throw bad("请填写操作原因"); }
/** 创建参数不满足业务要求的错误。 */
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
/** 创建状态或版本冲突的业务错误。 */
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -57,6 +57,17 @@ public class PlatformFleetTripController {
public ApiResponse<FleetTripModels.Detail> plan(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Plan request) {
return ApiResponse.success(service.updatePlan(tripNo, operator(user), request));
}
/** 仅平台查看本次候选的实时占用,沿车次列表权限,不产生资源预留。 */
@GetMapping("/{tripNo}/resource-occupancy")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<java.util.List<FleetTripModels.ResourceOccupancy>> resourceOccupancy(
@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid FleetTripModels.ResourceQuery query) {
if(user==null || !"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可以查询车次资源占用");
return ApiResponse.success(service.resourceOccupancy(tripNo,query));
}
/** 分配前重新锁定并核对车辆司机,候选空闲不代表已预留。 */
@PostMapping("/{tripNo}/assignment")
@PreAuthorize("hasAuthority('platform:fleet:trip:assign')")
public ApiResponse<FleetTripModels.Detail> assign(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Assign request) {
@@ -92,6 +92,7 @@ import java.util.stream.Collectors;
@Service
public class WarehouseTransferService {
@Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupService pickupService;
/** 退货单在转运来源中的展示编码,避免把退货单号误当成普通入仓码查询。 */
public static final String RETURN_ORDER_PREFIX = "RETURN_ORDER:";
@Resource private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
@@ -909,6 +910,7 @@ public class WarehouseTransferService {
WarehouseTransferRequests.CountCartons request) {
WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId));
if (transfer == null) throw notFound("当前仓没有该转运单");
if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer);
String customerId = transfer.getCustomerId();
List<WarehouseTransferCarton> cartons = transferCartons.listOrder(customerId, transfer.getId());
// 客户补箱后旧设备快照不可确认短收,必须刷新取得新增的预报箱唛。
@@ -1034,11 +1036,9 @@ public class WarehouseTransferService {
return cartons.stream().filter(box -> "COUNTED".equals(box.getStatus())).toList();
}
/** 上门取货必须先送仓;按 PDA 绑定仓锁定后继续使用原测量流程。 */
private WarehouseTransferOrder requirePdaMeasuringTransfer(long warehouseId, String transferNo) {
WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId));
if (transfer == null) throw notFound("Warehouse transfer order not found");
if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) throw conflict("Warehouse transfer is not awaiting PDA receiving");
return transfer;
return pickupService.requireMeasuringSource(String.valueOf(warehouseId), transferNo);
}
private void validatePalletMeasurement(BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal volume) {
@@ -1056,6 +1056,7 @@ public class WarehouseTransferService {
WarehouseTransferRequests.Palletize request) {
WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(overseasWarehouseId));
if (transfer == null) throw notFound("Warehouse transfer order not found");
if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer);
if (!String.valueOf(overseasWarehouseId).equals(transfer.getOperatingWarehouseId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Warehouse transfer belongs to another warehouse");
}
@@ -1622,6 +1623,7 @@ public class WarehouseTransferService {
String transferNo, MultipartFile file) {
WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(overseasWarehouseId));
if (transfer == null) throw notFound("Warehouse transfer order not found");
if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer);
if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) {
throw conflict("Measurement photos can only be uploaded before palletization completes");
}