提交
This commit is contained in:
@@ -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) { }
|
||||
}
|
||||
+58
@@ -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; }
|
||||
}
|
||||
+269
@@ -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;
|
||||
}
|
||||
+81
@@ -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();
|
||||
}
|
||||
}
|
||||
+55
@@ -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,"仅平台可以查询");
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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);}}
|
||||
|
||||
+4
-1
@@ -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;
|
||||
|
||||
+2
@@ -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); }
|
||||
}
|
||||
+117
-40
@@ -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); }
|
||||
}
|
||||
|
||||
+11
@@ -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) {
|
||||
|
||||
+6
-4
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user