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
@@ -8366,3 +8366,65 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3));
SELECT 'PASS' AS initialization_status, 'CN' AS region, 227 AS initialized_tables;
-- 2026-10-17 仓储转运上门取货,目标 baiyue_lastleg。
-- 目标数据库:baiyue_lastleg;仅增加结构,不修改既有业务数据,可重复执行。
USE baiyue_lastleg;
SET @pickup_column_sql = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_fleet_trip' AND column_name='trip_purpose')=0,
'ALTER TABLE ll_fleet_trip ADD COLUMN trip_purpose VARCHAR(16) NOT NULL DEFAULT ''DELIVERY'' COMMENT ''DELIVERY出库派送 PICKUP上门取货入仓''',
'SELECT 1');
PREPARE pickup_column_stmt FROM @pickup_column_sql;
EXECUTE pickup_column_stmt;
DEALLOCATE PREPARE pickup_column_stmt;
CREATE TABLE IF NOT EXISTS ll_fleet_pickup_plan (
trip_no VARCHAR(64) NOT NULL COMMENT '现有车辆车次号',
request_hash VARCHAR(64) NOT NULL COMMENT '创建来源与计划摘要',
PRIMARY KEY(trip_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运取货计划';
CREATE TABLE IF NOT EXISTS ll_fleet_pickup_task (
transfer_order_id BIGINT NOT NULL COMMENT '原仓储转运单ID,一票唯一',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键',
transfer_no VARCHAR(64) NOT NULL COMMENT '原仓储转运单号',
trip_no VARCHAR(64) NULL COMMENT '当前取货车次,解除后为空',
status VARCHAR(32) NOT NULL COMMENT 'WAITING待安排 ASSIGNED已安排 PICKED已取货 ARRIVED已送仓',
actual_packages INT NULL COMMENT '司机实提箱数,非仓库实收',
picked_at DATETIME NULL COMMENT '实际确认取货UTC时间',
arrived_at DATETIME NULL COMMENT '实际确认送仓UTC时间',
evidence_json LONGTEXT NULL COMMENT '本票已确认凭证元数据',
remark VARCHAR(1000) NULL COMMENT '取货备注',
updated_at DATETIME NOT NULL COMMENT '最近修改UTC时间',
PRIMARY KEY(transfer_order_id),
UNIQUE KEY uk_pickup_source(customer_id,transfer_no),
KEY idx_pickup_trip(trip_no,status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运逐票取货执行';
CREATE TABLE IF NOT EXISTS ll_fleet_pickup_command (
trip_no VARCHAR(64) NOT NULL COMMENT '取货车次号',
request_key VARCHAR(128) NOT NULL COMMENT '成功命令幂等键',
request_hash VARCHAR(64) NOT NULL COMMENT '命令动作与内容摘要',
PRIMARY KEY(trip_no,request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货命令幂等记录';
CREATE TABLE IF NOT EXISTS ll_fleet_pickup_upload (
transfer_order_id BIGINT NOT NULL COMMENT '凭证所属原单',
trip_no VARCHAR(64) NOT NULL COMMENT '凭证所属车次',
file_id BIGINT NOT NULL COMMENT '平台归属文件ID',
PRIMARY KEY(file_id),
KEY idx_pickup_upload_source(transfer_order_id,trip_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货上传凭证精确单据归属';
CREATE TABLE IF NOT EXISTS ll_fleet_pickup_history (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '操作流水',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键',
transfer_no VARCHAR(64) NOT NULL COMMENT '原转运单号',
trip_no VARCHAR(64) NOT NULL COMMENT '当时取货车次号',
action VARCHAR(32) NOT NULL COMMENT '安排解除取货到仓动作',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
occurred_at DATETIME NOT NULL COMMENT '操作UTC时间',
detail VARCHAR(2000) NULL COMMENT '操作说明',
PRIMARY KEY(id),
KEY idx_pickup_history_source(customer_id,transfer_no,id),
KEY idx_pickup_history_trip(trip_no,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
@@ -0,0 +1,15 @@
# 仓储转运上门取货与车辆排车
客户选择仓库上门取货并提交,平台审核通过后,原转运单进入车辆排车的“待取货”。一个取货车次可取多名客户的多票货,目的海外仓必须相同。客户自送、未审核、库存转运和测量已完成的订单不进入候选。
业务入口为 `fleet/pickup/PlatformFleetPickupController`,按 Controller → Service → Repository → Mapper 持久化,复用原车次编号、车辆司机分配和资源占用。`tripPurpose=PICKUP` 区别于原 `DELIVERY`,取货车次不关联出库排车、不发布拼车余位、不执行原出库装载和扣库存。
车次按 `PLANNED → IN_TRANSIT → COMPLETED` 执行。逐票取货任务为 `ASSIGNED → PICKED → ARRIVED`:发车复验车辆司机资格和实际占用;取货必须填写实提箱数并上传本票凭证;到仓后才开放原 PDA 清点测量。实提箱数独立保存,不覆盖客户预报箱数、SKU件数和箱唛。全车送仓可结束运输,车辆与司机任务分别沿原入口释放,不需要等仓内测量。
计划阶段取消车次会保留操作历史、释放资源和原单关联,使原单重新可安排;发车后禁止取消。原车次分配入口支持发车前改派。所有取货命令在车次行锁、版本校验和持久幂等摘要下执行;来源安排同时锁原单,避免一单多车。前端遇到结果未知保留原请求和请求键,阻止刷新卸载或改派后重新生成命令。
平台待取货使用服务端分页;客户详情、平台尾程详情按原转运单查询同一运输进度。凭证只允许当前单已确认引用,本车其他客户文件不能借用或下载。文件数据归 File 服务,Lastleg仅持有文件归属和引用。
公开接口与请求字段见 `src/main/resources/openapi/fleet-transfer-pickup-v1.yaml`。数据库增量为 `../../scripts/db/fleet-transfer-pickup-20261017.sql`,目标 `baiyue_lastleg`,可重复执行,无业务 DML。
本机验收入口 `../../scripts/local-transfer-pickup-acceptance.mjs` 创建带 TEST 标记的新业务记录并保存报告。覆盖待取货、未审核、自送、异仓拒绝、共车、取消改派、资源冲突、同键重放与异载荷拒绝、跨客户文件隔离、未送仓禁止PDA和送仓后完整测量。合成图片仅验证上传与文件归属,不证明物理取货。本机构建、单测和HTTP验收与远端部署、真实页面视觉验收分别记录。
@@ -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");
}
@@ -1086,6 +1086,7 @@ CREATE TABLE ll_fleet_vehicle_record_history (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录不可变历史';
CREATE TABLE ll_fleet_trip (
trip_purpose VARCHAR(16) NOT NULL DEFAULT 'DELIVERY' COMMENT 'DELIVERY出库派送 PICKUP上门取货入仓',
id BIGINT NOT NULL COMMENT '内部主键',
trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号',
idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键',
@@ -4324,3 +4325,56 @@ CREATE TABLE IF NOT EXISTS ll_compact_number_clock (
-- 库存转运无需再次预约入仓;目标库:lastleg-service 所属数据库。可重复执行。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_arrival_at' AND is_nullable='NO'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT ''预计入仓时间,库存转运不填写''', 'SELECT 1'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 仓储转运上门取货,共用车辆和司机资源。
CREATE TABLE IF NOT EXISTS ll_fleet_pickup_plan (
trip_no VARCHAR(64) NOT NULL COMMENT '现有车辆车次号',
request_hash VARCHAR(64) NOT NULL COMMENT '创建来源与计划摘要',
PRIMARY KEY(trip_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运取货计划';
CREATE TABLE IF NOT EXISTS ll_fleet_pickup_task (
transfer_order_id BIGINT NOT NULL COMMENT '原仓储转运单ID,一票唯一',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键',
transfer_no VARCHAR(64) NOT NULL COMMENT '原仓储转运单号',
trip_no VARCHAR(64) NULL COMMENT '当前取货车次,解除后为空',
status VARCHAR(32) NOT NULL COMMENT 'WAITING待安排 ASSIGNED已安排 PICKED已取货 ARRIVED已送仓',
actual_packages INT NULL COMMENT '司机实提箱数,非仓库实收',
picked_at DATETIME NULL COMMENT '实际确认取货UTC时间',
arrived_at DATETIME NULL COMMENT '实际确认送仓UTC时间',
evidence_json LONGTEXT NULL COMMENT '本票已确认凭证元数据',
remark VARCHAR(1000) NULL COMMENT '取货备注',
updated_at DATETIME NOT NULL COMMENT '最近修改UTC时间',
PRIMARY KEY(transfer_order_id),
UNIQUE KEY uk_pickup_source(customer_id,transfer_no),
KEY idx_pickup_trip(trip_no,status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运逐票取货执行';
CREATE TABLE IF NOT EXISTS ll_fleet_pickup_command (
trip_no VARCHAR(64) NOT NULL COMMENT '取货车次号',
request_key VARCHAR(128) NOT NULL COMMENT '成功命令幂等键',
request_hash VARCHAR(64) NOT NULL COMMENT '命令动作与内容摘要',
PRIMARY KEY(trip_no,request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货命令幂等记录';
CREATE TABLE IF NOT EXISTS ll_fleet_pickup_upload (
transfer_order_id BIGINT NOT NULL COMMENT '凭证所属原单',
trip_no VARCHAR(64) NOT NULL COMMENT '凭证所属车次',
file_id BIGINT NOT NULL COMMENT '平台归属文件ID',
PRIMARY KEY(file_id),
KEY idx_pickup_upload_source(transfer_order_id,trip_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货上传凭证精确单据归属';
CREATE TABLE IF NOT EXISTS ll_fleet_pickup_history (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '操作流水',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键',
transfer_no VARCHAR(64) NOT NULL COMMENT '原转运单号',
trip_no VARCHAR(64) NOT NULL COMMENT '当时取货车次号',
action VARCHAR(32) NOT NULL COMMENT '安排解除取货到仓动作',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
occurred_at DATETIME NOT NULL COMMENT '操作UTC时间',
detail VARCHAR(2000) NULL COMMENT '操作说明',
PRIMARY KEY(id),
KEY idx_pickup_history_source(customer_id,transfer_no,id),
KEY idx_pickup_history_trip(trip_no,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
@@ -0,0 +1,159 @@
openapi: 3.0.3
info:
title: 仓储转运上门取货与车辆排车
version: 1.0.0
description: |
审核通过的 WAREHOUSE_PICKUP 外来转运单自动进入待取货候选;客户自送、库存转运、未审核及已完成测量单不进入。
入仓取货和出库派送共用 ll_fleet_trip 及车辆司机占用,tripPurpose 分别为 PICKUP / DELIVERY,禁止混用库存出库命令、外接预订及余位发布。
同车多单必须送往同一公共海外仓,原客户归属与转运单保留,不重复生成尾程单、不修改预报箱唛或库存。
取货确认必须填写实提箱数并引用本票本车已上传的 PDF/JPG/PNG 凭证;送仓后开放 PDA 清点。
司机实提箱数不替代仓库实收。全部送仓后结案运输,车辆和司机仍通过原 resources/release 分别结束任务。
取消 PLANNED 车次原子释放资源和未执行取货关联,原单重新成为候选;已发车不可取消。
写命令具备车次版本及请求键,同键同内容重放不重复写入,同键不同内容拒绝。普通业务返回 HTTP 200 + body.code。
security:
- bearerAuth: []
paths:
/api/platform/v1/last-leg/fleet/pickups:
get:
summary: 分页读取待取货需求
description: 仅平台主体,权限 platform:fleet:trip:list。返回客户与原单、取货地址联系人、预报箱数、期望入仓时间及仓库名称编码。
parameters:
- {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}}
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
responses: {'200': {description: ApiResponse 分页候选}}
post:
summary: 选单创建同仓多点取货车次
description: 仅平台主体,权限 platform:fleet:trip:create;创建后沿原 assignment 接口分配车辆司机,复用原 plan 接口调整时间,取货路线由原单地址确定。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [sources, plannedDepartureAt, expectedDriverEndAt, idempotencyKey]
properties:
sources:
type: array
minItems: 1
maxItems: 100
items:
type: object
required: [customerId, transferNo]
properties:
customerId: {type: string}
transferNo: {type: string}
plannedDepartureAt: {type: string, format: date-time}
expectedDriverEndAt: {type: string, format: date-time}
remark: {type: string, maxLength: 1000}
idempotencyKey: {type: string, minLength: 8, maxLength: 128}
responses: {'200': {description: ApiResponse FleetTripModels.Detail,trip.tripPurpose=PICKUP}}
/api/platform/v1/last-leg/fleet/pickups/{tripNo}/tasks:
parameters: [{$ref: '#/components/parameters/tripNo'}]
get:
summary: 读取本车所有逐票取货任务
description: 仅平台主体,权限 platform:fleet:trip:list。任务状态 WAITING / ASSIGNED / PICKED / ARRIVED;凭证按本票返回,不暴露文件公共直链。
responses: {'200': {description: ApiResponse Task 数组}}
/api/platform/v1/last-leg/fleet/pickups/{tripNo}/depart:
parameters: [{$ref: '#/components/parameters/tripNo'}]
post:
summary: 确认取货发车
description: 仅平台主体,权限 platform:fleet:trip:assign。复验车辆状态、司机资格、任务覆盖和两类资源占用。只推进车次 IN_TRANSIT,不触发出库。
requestBody: {$ref: '#/components/requestBodies/command'}
responses: {'200': {description: ApiResponse FleetTripModels.Detail}}
/api/platform/v1/last-leg/fleet/pickups/{tripNo}/tasks/{customerId}/{transferNo}/files:
parameters: [{$ref: '#/components/parameters/tripNo'}, {$ref: '#/components/parameters/customerId'}, {$ref: '#/components/parameters/transferNo'}]
post:
summary: 上传本票取货凭证
description: 仅平台主体,权限 platform:fleet:trip:edit。最大10MiB,File沿 FLEET_TRIP_OPERATION_EVIDENCE / tenantId=0 / businessId=车次ID校验格式;Lastleg另存当前票上传归属。
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties: {file: {type: string, format: binary}}
responses: {'200': {description: ApiResponse 文件ID、名称、类型和大小}}
/api/platform/v1/last-leg/fleet/pickups/{tripNo}/tasks/{customerId}/{transferNo}/picked:
parameters: [{$ref: '#/components/parameters/tripNo'}, {$ref: '#/components/parameters/customerId'}, {$ref: '#/components/parameters/transferNo'}]
post:
summary: 逐票确认取货
description: 仅平台主体,权限 platform:fleet:trip:edit。车次已发车且任务 ASSIGNED,核对本票本车上传归属后转 PICKED,实提箱数不覆盖预报。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [recordVersion, idempotencyKey, actualPackages, evidenceFileIds]
properties:
recordVersion: {type: integer, format: int64, minimum: 0}
idempotencyKey: {type: string, minLength: 8, maxLength: 128}
actualPackages: {type: integer, minimum: 1, maximum: 5000}
evidenceFileIds: {type: array, minItems: 1, maxItems: 10, items: {type: string, description: 正Long文件ID}}
remark: {type: string, maxLength: 1000}
responses: {'200': {description: ApiResponse 当前本车 Task 数组;车次版本已更新}}
/api/platform/v1/last-leg/fleet/pickups/{tripNo}/tasks/{customerId}/{transferNo}/arrive:
parameters: [{$ref: '#/components/parameters/tripNo'}, {$ref: '#/components/parameters/customerId'}, {$ref: '#/components/parameters/transferNo'}]
post:
summary: 确认本票送达海外仓
description: 仅平台主体,权限 platform:fleet:trip:edit。仅 PICKED 可以转 ARRIVED;开放原 PDA 清点,不直接完成测量或入库。
requestBody: {$ref: '#/components/requestBodies/command'}
responses: {'200': {description: ApiResponse 当前本车 Task 数组;车次版本已更新}}
/api/platform/v1/last-leg/fleet/pickups/{tripNo}/complete:
parameters: [{$ref: '#/components/parameters/tripNo'}]
post:
summary: 全车送仓后结案取货运输
description: 仅平台主体,权限 platform:fleet:trip:complete。全车非空且均 ARRIVED,车次转 COMPLETED;原单继续仓库作业,不自动释放车和司机。
requestBody: {$ref: '#/components/requestBodies/command'}
responses: {'200': {description: ApiResponse FleetTripModels.Detail}}
/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/pickup-progress:
parameters: [{$ref: '#/components/parameters/transferNo'}]
get:
summary: 客户读取本企业本票取货安排
description: 权限 merchant:overseas-warehouse:transfer:list;客户ID从认证上下文取得,自送及库存来源返回null。仅返回本票运输与凭证,不授予整车读取。
responses: {'200': {description: ApiResponse Task或null}}
/api/platform/v1/last-leg/warehouse-transfers/{transferNo}/pickup-progress:
parameters:
- {$ref: '#/components/parameters/transferNo'}
- {name: customerId, in: query, required: true, schema: {type: string}}
get:
summary: 平台尾程详情读取本票取货安排
description: 仅平台主体,权限 platform:overseas-warehouse:last-mile-order:list。
responses: {'200': {description: ApiResponse Task或null}}
/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/pickup-evidence/{fileId}:
parameters: [{$ref: '#/components/parameters/transferNo'}, {$ref: '#/components/parameters/fileId'}]
get:
summary: 客户下载本票已确认凭证
description: 权限 merchant:overseas-warehouse:transfer:list;复验客户、原订单、文件引用及File归属,no-store。
responses: {'200': {description: application/octet-stream 文件;业务错误仍为ApiResponse}}
/api/platform/v1/last-leg/warehouse-transfers/{transferNo}/pickup-evidence/{fileId}:
parameters:
- {$ref: '#/components/parameters/transferNo'}
- {$ref: '#/components/parameters/fileId'}
- {name: customerId, in: query, required: true, schema: {type: string}}
get:
summary: 平台下载本票已确认凭证
description: 仅平台主体,权限 platform:fleet:trip:list 或 platform:overseas-warehouse:last-mile-order:list;复验本票文件引用,不因共车授权其他客户文件。
responses: {'200': {description: application/octet-stream 文件;业务错误仍为ApiResponse}}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
parameters:
tripNo: {name: tripNo, in: path, required: true, schema: {type: string}}
customerId: {name: customerId, in: path, required: true, schema: {type: string}}
transferNo: {name: transferNo, in: path, required: true, schema: {type: string}}
fileId: {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}
requestBodies:
command:
required: true
content:
application/json:
schema:
type: object
required: [recordVersion, idempotencyKey, reason]
properties:
recordVersion: {type: integer, format: int64, minimum: 0}
idempotencyKey: {type: string, minLength: 8, maxLength: 128}
reason: {type: string, maxLength: 1000}
@@ -27,6 +27,36 @@ info:
security:
- bearerAuth: []
paths:
/api/platform/v1/last-leg/fleet/trips/{tripNo}/resource-occupancy:
get:
summary: 批量读取当前下拉候选的实际车辆或司机占用
description: 仅平台且须platform:fleet:trip:list。本车已占用仍可保留;别车占用不可选。只反映占用,不代表证照或容量合格,不产生资源预留,保存分配时再次复验。
parameters:
- {in: path, name: tripNo, required: true, schema: {type: string}}
- {in: query, name: kind, required: true, schema: {type: string, enum: [VEHICLE, DRIVER]}}
- in: query
name: resourceIds
required: true
style: form
explode: false
schema: {type: array, minItems: 1, maxItems: 100, items: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200':
description: 标准业务响应;data为候选占用数组,HTTP200亦需检查code。业务无权限、参数错误及资源不存在沿统一错误码。
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data:
type: array
items:
type: object
properties:
resourceId: {type: string}
occupiedTripNo: {type: string, nullable: true}
available: {type: boolean}
/api/platform/v1/last-leg/fleet/trips/calendar:
get:
summary: 读取美国车次用车日历来源
@@ -746,6 +776,7 @@ components:
type: object
properties:
tripNo: {type: string}
tripPurpose: {type: string, enum: [DELIVERY, PICKUP], description: 派送车次或入仓取货车次}
status: {type: string, enum: [PLANNED, IN_TRANSIT, COMPLETED, CANCELLED]}
warehouseId: {type: string}
warehouseCode: {type: string}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.fleet.pickup;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.h2.jdbcx.JdbcDataSource;
import org.mybatis.spring.SqlSessionTemplate;
import org.junit.jupiter.api.*;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.assertj.core.api.Assertions.*;
class FleetPickupMapperTest {
JdbcTemplate db;FleetPickupMapper mapper;
@BeforeEach void setup() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:pickup_"+System.nanoTime()+";MODE=MySQL;DB_CLOSE_DELAY=-1");db=new JdbcTemplate(ds);
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),transfer_no VARCHAR(64),inbound_code VARCHAR(64),lastleg_order_no VARCHAR(64),operating_warehouse_id VARCHAR(64),delivery_address VARCHAR(500),pickup_contact_name VARCHAR(100),pickup_contact_phone VARCHAR(40),package_count INT,estimated_arrival_at TIMESTAMP,status VARCHAR(32),delivery_method VARCHAR(40),created_at TIMESTAMP)");
db.execute("CREATE TABLE ll_fleet_pickup_task(transfer_order_id BIGINT PRIMARY KEY,customer_id VARCHAR(64),transfer_no VARCHAR(64),trip_no VARCHAR(64),status VARCHAR(32),updated_at TIMESTAMP)");
for(int i=1;i<=6;i++)db.update("INSERT INTO ll_warehouse_transfer_order VALUES(?,? ,? ,? ,? ,'100','Ontario Test Address','Contact','0000000000',5,CURRENT_TIMESTAMP,'WAIT_PDA_RECEIVING','WAREHOUSE_PICKUP',CURRENT_TIMESTAMP)",i,"C"+i,"WT-"+i,"WT-"+i,"LL-"+i);
db.update("UPDATE ll_warehouse_transfer_order SET delivery_method='CUSTOMER_DELIVERY' WHERE id=2");
db.update("UPDATE ll_warehouse_transfer_order SET status='PENDING_REVIEW' WHERE id=3");
db.update("UPDATE ll_warehouse_transfer_order SET status='WAIT_CUSTOMER_CONFIRMATION' WHERE id=4");
db.update("UPDATE ll_warehouse_transfer_order SET inbound_code='OLD-INBOUND' WHERE id=5");
var config=new MybatisConfiguration();config.addMapper(FleetPickupMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetPickupMapper.class);
}
@Test void candidateQualificationPaginationAndCustomerScopeUseActualSql() {
assertThat(mapper.countCandidates(null)).isEqualTo(2);
assertThat(mapper.candidates(null,1,0)).extracting(FleetPickupModels.Candidate::transferNo).containsExactly("WT-1");
assertThat(mapper.candidates(null,1,1)).extracting(FleetPickupModels.Candidate::transferNo).containsExactly("WT-6");
assertThat(mapper.countCandidates("WT-6")).isEqualTo(1);
assertThat(mapper.source("C2","WT-1")).isNull();assertThat(mapper.source("C1","WT-1").getPackageCount()).isEqualTo(5);
}
@Test void assignmentDisappearsFromPoolAndCancellationRestoresIt() {
db.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'C1','WT-1','TR-1','ASSIGNED',CURRENT_TIMESTAMP)");
assertThat(mapper.countCandidates(null)).isEqualTo(1);assertThat(mapper.unassign("TR-1")).isEqualTo(1);
assertThat(mapper.countCandidates(null)).isEqualTo(2);assertThat(mapper.task(1L).getTripNo()).isNull();
}
@Test void pickedCargoCannotBeReleasedBackToPendingByCancellationSql() {
db.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'C1','WT-1','TR-1','PICKED',CURRENT_TIMESTAMP)");
assertThat(mapper.unassign("TR-1")).isZero();assertThat(mapper.task(1L).getTripNo()).isEqualTo("TR-1");
}
}
@@ -0,0 +1,106 @@
package com.baiyue.lastleg.fleet.pickup;
import com.baiyue.lastleg.fleet.driver.*;
import com.baiyue.lastleg.fleet.reporting.*;
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.databind.ObjectMapper;
import org.junit.jupiter.api.*;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class FleetPickupServiceTest {
FleetPickupRepository repository;FleetTripService trips;FleetPickupService service;
TripOperationsEvidenceGateway files;FleetTripEntity trip;WarehouseTransferOrder source;FleetPickupTask task;
final Map<String,String> commands=new HashMap<>();
@BeforeEach void setup() {
repository=mock(FleetPickupRepository.class);trips=mock(FleetTripService.class);files=mock(TripOperationsEvidenceGateway.class);
service=new FleetPickupService(repository,trips,mock(FleetWarehouseDirectory.class),mock(DriverService.class),mock(VehicleRepository.class),files,new ObjectMapper().findAndRegisterModules());
trip=new FleetTripEntity();trip.setId(9L);trip.setTripNo("TR-1");trip.setTripPurpose("PICKUP");trip.setStatus("IN_TRANSIT");trip.setWarehouseId(100L);trip.setRecordVersion(2L);
source=source(1L,"C1","WT-1","100");task=new FleetPickupTask();task.setTransferOrderId(1L);task.setCustomerId("C1");task.setTransferNo("WT-1");task.setTripNo("TR-1");task.setStatus("ASSIGNED");
when(repository.trip("TR-1",true)).thenReturn(trip);when(repository.trip("TR-1",false)).thenReturn(trip);
when(repository.source(eq("C1"),eq("WT-1"),anyBoolean())).thenReturn(source);
when(repository.task(1L)).thenReturn(task);when(repository.lockTask(1L)).thenReturn(task);when(repository.tasks("TR-1")).thenAnswer(c->List.of(task));
when(repository.commandHash(eq("TR-1"),anyString())).thenAnswer(c->commands.get(c.getArgument(1)));
doAnswer(c->{commands.put(c.getArgument(1),c.getArgument(2));return null;}).when(repository).command(anyString(),anyString(),anyString());
doAnswer(c->{trip.setRecordVersion(trip.getRecordVersion()+1);return null;}).when(trips).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());
}
@Test void pickupMustArriveBeforePdaAndWrongWarehouseCannotRead() {
when(repository.pdaSource("100","WT-1")).thenReturn(source);
assertThatThrownBy(()->service.requireMeasuringSource("100","WT-1")).hasMessageContaining("先完成车辆取货送仓");
task.setStatus("ARRIVED");assertThat(service.requireMeasuringSource("100","WT-1")).isSameAs(source);
assertThatThrownBy(()->service.requireMeasuringSource("200","WT-1")).isInstanceOf(ResponseStatusException.class);
}
@Test void selfDeliveryKeepsOriginalMeasurementPath() {
source.setDeliveryMethod("CUSTOMER_DELIVERY");when(repository.pdaSource("100","WT-1")).thenReturn(source);
assertThat(service.requireMeasuringSource("100","WT-1")).isSameAs(source);verify(repository,never()).task(anyLong());
}
@Test void cannotScheduleSelfDeliveryOrAnotherWarehouseOrDuplicateSource() {
var plan=new FleetPickupModels.Create(List.of(new FleetPickupModels.Source("C1","WT-1")),Instant.now(),Instant.now().plusSeconds(7200),null,"create-key-1");
source.setDeliveryMethod("CUSTOMER_DELIVERY");assertThatThrownBy(()->service.create("OP",plan)).hasMessageContaining("不是已审核");
source.setDeliveryMethod("WAREHOUSE_PICKUP");when(repository.task(1L)).thenReturn(null);when(repository.lockTask(1L)).thenReturn(null);
var other=source(2L,"C2","WT-2","200");when(repository.source("C2","WT-2",true)).thenReturn(other);
var mixed=new FleetPickupModels.Create(List.of(new FleetPickupModels.Source("C1","WT-1"),new FleetPickupModels.Source("C2","WT-2")),plan.plannedDepartureAt(),plan.expectedDriverEndAt(),null,"create-key-2");
assertThatThrownBy(()->service.create("OP",mixed)).hasMessageContaining("同一海外仓");verify(trips,never()).create(anyString(),any());
var duplicate=new FleetPickupModels.Create(List.of(plan.sources().get(0),plan.sources().get(0)),plan.plannedDepartureAt(),plan.expectedDriverEndAt(),null,"create-key-3");
assertThatThrownBy(()->service.create("OP",duplicate)).hasMessageContaining("重复选择");
}
@Test void alreadyScheduledSourceIsNotScheduledAgain() {
var plan=new FleetPickupModels.Create(List.of(new FleetPickupModels.Source("C1","WT-1")),Instant.now(),Instant.now().plusSeconds(7200),null,"create-key-1");
assertThatThrownBy(()->service.create("OP",plan)).hasMessageContaining("已安排车次");verify(trips,never()).create(anyString(),any());
}
@Test void pickupQuantityDoesNotOverwriteForecastAndReplayDoesNotWriteTwice() {
when(repository.ownsUpload(1L,"TR-1",88L)).thenReturn(true);
when(files.metadata(9L,88L)).thenReturn(new TripOperationsRecordModels.EvidenceFile("88","取货现场.jpg","image/jpeg",500));
var request=new FleetPickupModels.Pickup(2L,"pickup-key-1",7,List.of(88L),"现场多提两箱,等待仓库核实");
var result=service.picked("TR-1","C1","WT-1","OP",request);
assertThat(task.getActualPackages()).isEqualTo(7);assertThat(source.getPackageCount()).isEqualTo(5);assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING");
assertThat(result.get(0).status()).isEqualTo("PICKED");assertThat(result.get(0).evidence()).hasSize(1);
service.picked("TR-1","C1","WT-1","OP",request);verify(repository,times(1)).update(task);
var changed=new FleetPickupModels.Pickup(2L,"pickup-key-1",9,List.of(88L),null);
assertThatThrownBy(()->service.picked("TR-1","C1","WT-1","OP",changed)).hasMessageContaining("不能提交不同");
}
@Test void evidenceCannotCrossCustomerEvenWithinTheSameTrip() {
assertThatThrownBy(()->service.picked("TR-1","C1","WT-1","OP",new FleetPickupModels.Pickup(2L,"pickup-key-1",5,List.of(99L),null))).hasMessageContaining("不属于当前订单");
assertThatThrownBy(()->service.evidence("C1","WT-1",99L)).hasMessageContaining("没有该取货凭证");
verifyNoInteractions(files);verify(repository,never()).update(any());
}
@Test void cannotArriveBeforePickupAndCannotCompleteWithPendingMembers() {
var command=new FleetPickupModels.Command(2L,"arrive-key-1","确认送仓");
assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",command)).hasMessageContaining("先确认该单已取货");
assertThatThrownBy(()->service.complete("TR-1","OP",command)).hasMessageContaining("全部取货订单");
}
@Test void arrivalOpensPdaAndCompletionKeepsWarehouseOrderInMeasurement() {
task.setStatus("PICKED");task.setActualPackages(5);task.setPickedAt(LocalDateTime.now(ZoneOffset.UTC));
service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(2L,"arrive-key-1","已送海外仓月台"));
assertThat(task.getStatus()).isEqualTo("ARRIVED");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING");
service.complete("TR-1","OP",new FleetPickupModels.Command(3L,"complete-key-1","全部送仓"));
assertThat(trip.getStatus()).isEqualTo("COMPLETED");assertThat(trip.getCompletionJson()).contains("complete-key-1");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING");
}
@Test void staleVersionAndWrongTripPurposeAreRejected() {
assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(1L,"arrive-key-1","送仓"))).hasMessageContaining("车次已变化");
trip.setTripPurpose("DELIVERY");assertThatThrownBy(()->service.tasks("TR-1")).hasMessageContaining("不是入仓取货");
}
/** 车次展示的资源不能代替真实占用事实。 */
@Test void departureRequiresBothResourcesAndTheirCurrentOwnership() {
trip.setStatus("PLANNED");
var command=new FleetPickupModels.Command(2L,"depart-key-1","开始取货");
assertThatThrownBy(()->service.depart("TR-1","OP",command)).hasMessageContaining("先分配车辆和司机");
trip.setVehicleId(20L);trip.setDriverId(30L);
when(repository.ownsResources(9L,20L,30L)).thenReturn(false);
assertThatThrownBy(()->service.depart("TR-1","OP",command)).hasMessageContaining("占用与当前车次不一致");
assertThat(trip.getStatus()).isEqualTo("PLANNED");
verify(trips,never()).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());
}
/** 构造有业务含义的原转运单,箱唛与库存由原链路持有。 */
static WarehouseTransferOrder source(long id,String customer,String number,String warehouse) {
var row=new WarehouseTransferOrder();row.setId(id);row.setCustomerId(customer);row.setTransferNo(number);row.setInboundCode(number);
row.setOperatingWarehouseId(warehouse);row.setDeliveryMethod("WAREHOUSE_PICKUP");row.setStatus("WAIT_PDA_RECEIVING");row.setPackageCount(5);
row.setDeliveryAddress("1580 S Grove Ave, Ontario, CA 91761");row.setPickupContactName("取货测试联系人");row.setPickupContactPhone("0000000000");return row;
}
}
@@ -186,8 +186,8 @@ class FleetTripPersistenceTest {
ReflectionTestUtils.setField(dispatchGateway, "deliveryExecution", deliveryExecution);
ReflectionTestUtils.setField(dispatchGateway, "cargoSources", new com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery(
new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(session.getMapper(com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper.class))));
service = transactional(new FleetTripService(session.getMapper(FleetTripMapper.class), resources, session.getMapper(FleetTripHistoryMapper.class), vehicles, drivers, directory, json,
bindingMapper, dispatchGateway, reservations), FleetTripService.class);
service = transactional(new FleetTripService(new FleetTripRepository(session.getMapper(FleetTripMapper.class), resources, session.getMapper(FleetTripHistoryMapper.class), bindingMapper), vehicles, drivers, directory, json,
dispatchGateway, reservations), FleetTripService.class);
preparation = transactional(new FleetTripPreparationService(service,session.getMapper(FleetTripMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class),json),FleetTripPreparationService.class);
planning=mock(FleetDispatchPlanningGateway.class);
ReflectionTestUtils.setField(service,"planning",planning);
@@ -368,6 +368,25 @@ class FleetTripPersistenceTest {
assertThat(service.assign(second, "1", new FleetTripModels.Assign(0L, vehicle, driver, "重新安排")).trip().vehicleId()).isEqualTo(Long.toString(vehicle));
}
/** 下拉占用来自真实资源表,本车可保留、别车不可选,查询不预留资源。 */
@Test void candidateOccupancySeparatesResourceKindAndReleasesAfterCancellation() {
long occupiedVehicle=vehicle(true), freeVehicle=vehicle(true), occupiedDriver=driver(true);
String first=trip(), second=trip();
service.assign(first,"1",new FleetTripModels.Assign(0L,occupiedVehicle,occupiedDriver,"候选占用验收"));
var query=new FleetTripModels.ResourceQuery("VEHICLE",List.of(occupiedVehicle,freeVehicle,occupiedVehicle));
var other=service.resourceOccupancy(second,query);
assertThat(other).hasSize(2);
assertThat(other.get(0)).isEqualTo(new FleetTripModels.ResourceOccupancy(Long.toString(occupiedVehicle),first,false));
assertThat(other.get(1)).isEqualTo(new FleetTripModels.ResourceOccupancy(Long.toString(freeVehicle),null,true));
assertThat(service.resourceOccupancy(first,query).get(0).available()).isTrue();
assertThat(service.resourceOccupancy(second,new FleetTripModels.ResourceQuery("DRIVER",List.of(occupiedDriver))).get(0).available()).isFalse();
assertThat(service.resourceOccupancy(second,new FleetTripModels.ResourceQuery("VEHICLE",List.of(occupiedDriver))).get(0).available()).isTrue();
assertThatThrownBy(()->service.resourceOccupancy(second,new FleetTripModels.ResourceQuery("VEHICLE",List.of()))).hasMessageContaining("有效的车辆或司机候选");
service.cancel(first,"1",new FleetTripModels.Cancel(1L,"取消释放候选资源"));
assertThat(service.resourceOccupancy(second,query)).allMatch(FleetTripModels.ResourceOccupancy::available);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Integer.class)).isZero();
}
@Test void failedReassignmentKeepsOldResourcesAndRollsBackTheNewVehicleClaim() {
long oldVehicle = vehicle(true), occupiedVehicle = vehicle(true), replacement = vehicle(true);
long oldDriver = driver(true), occupiedDriver = driver(true);
@@ -26,6 +26,9 @@ class WarehouseTransferCartonCountTest {
@BeforeEach void setup() {
service = spy(new WarehouseTransferService());
ReflectionTestUtils.setField(service, "transfers", orders);
var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class);
org.mockito.Mockito.lenient().when(pickupRepository.pdaSource(anyString(), anyString())).thenAnswer(call -> orders.lockForPda(call.getArgument(1), call.getArgument(0)));
ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper()));
ReflectionTestUtils.setField(service, "transferCartons", cartons);
ReflectionTestUtils.setField(service, "transferItems", items);
ReflectionTestUtils.setField(service, "transferPallets", palletMapper);
@@ -231,6 +231,9 @@ class WarehouseTransferServiceTest {
return work.apply(new WarehouseTransferReceiptCoordinator.Context(target, 51L, 61L));
});
ReflectionTestUtils.setField(service, "transfers", transfers);
var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class);
org.mockito.Mockito.lenient().when(pickupRepository.pdaSource(anyString(), anyString())).thenAnswer(call -> transfers.lockForPda(call.getArgument(1), call.getArgument(0)));
ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper()));
ReflectionTestUtils.setField(service, "transferItems", transferItems);
ReflectionTestUtils.setField(service, "transferCartons", transferCartons);
ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders);
+4
View File
@@ -1,5 +1,9 @@
# 后端脚本管理与全量 HTTP 验收索引
`local-transfer-pickup-acceptance.mjs`:仅本机 LOCAL 仓储转运取货造数与 HTTP 验收,拒绝非 localhost/127.0.0.1 Gateway。通过 `node --env-file=<本轮明确的本地配置> scripts/local-transfer-pickup-acceptance.mjs` 注入 `ADMIN_TEST_GATEWAY`、`ADMIN_TEST_USERNAME`、`ADMIN_TEST_PASSWORD`、`LOCAL_TRANSFER_PDA_PASSWORD`,不猜测或读取其他环境文件。创建16张明确 TEST 标记的业务单、专用PDA账号、司机车辆和多状态取货车次,含客户下单、跨客户共车、取消改派、资源占用、凭证隔离、送仓门禁与完整PDA测量。所有业务均走正式 Gateway HTTP,不用业务 SQL;文件是合成测试 PNG,不代表实际拍照或有效证照。报告默认 `reports/PICKUP-<新批次>.json`,可用 `PICKUP_ACCEPTANCE_OUTPUT` 指定;写入前保存脱敏意图与幂等键,逐阶段落盘。失败立即停止并保留已成功数据及报告,未知结果先核对原请求,不自动重发;重新执行会创建新批次,不自动续跑或清理旧记录。人工核实原请求结果后,可显式用 `PICKUP_ACCEPTANCE_RESUME=<本轮报告>` 沿已知单号继续验收;已成功批次禁止重复执行。脚本记录每次执行范围与结果,旧失败不删除;本机缺失的正式车次取消/结案/释放权限仅补权限资源并授权新建专用验收角色,不修改现有角色授权。账号和单据保留供本地页面验收,不能作为远端 TEST/生产或视觉验收结论。
`db/fleet-transfer-pickup-20261017.sql`:本功能 MySQL8 可重复执行结构增量,显式指向 `baiyue_lastleg`。为原车次增加 `trip_purpose`,新增取货计划摘要、原单任务、命令幂等、上传归属及历史五表;不写业务数据。结构同步于模块 `lastleg.sql` 和统一部署增量。需在已授权环境用运行者显式数据库凭据执行;失败保留诊断,修正后可重复执行,不删旧结构或业务数据。
`appointment-work-test-env.mjs`:预约事项与稳定批次 TEST 定向 HTTP 验收,默认 `--plan` 零网络;显式 `--execute --environment=TEST --run-id=<新批次> --mode=read|workflow --fixture=<本轮清单>` 才执行。运行者用 `node --env-file=<明确TEST配置>` 注入平台、客户及只读平台凭据,变量名和清单合同见 `--plan`。清单必须引用本轮正式业务生产者报告及 SHA256,不借用历史业务样本。read 只登录、查询、退出自身会话;workflow 还写一项跟进并验证原键重放、陈旧版本、异载荷和权限拒绝,可按清单登记客户仓无需预约要求,不代替排车/PDA/签收原域事实。报告写入独占的新 `reports/appointment-work-<run-id>/`;失败保留报告和业务记录,不自动重发未知结果、删除、重置或执行 SQL。离线合同为 `appointment-work-test-env.test.mjs`;只读或离线通过不代表完整运输履约、真实页面通过。
`../lastleg-service/scripts/selfoperated-test-env-acceptance.mjs`:自营统计 TEST Gateway 验收,默认仅输出计划;显式 `--execute --environment TEST --source-account <账号键> --warehouse <仓库编码> --other-warehouse <第二仓> --report <报告路径>` 才登录并查询。凭据由运行者通过脚本之前的 `node --env-file=<明确TEST文件>` 注入,需平台、只读平台、无权限平台及客户身份。`--probe-off-command` 仅在确认 OFF 后提交预期被拒绝的命令并核对无新增任务;不采集、发布、造数或写 SQL。`--synthetic-fixture <审核后的说明文件>` 验证合成样本,不能证明真实来源覆盖。报告脱敏记录各项结果及请求标识;失败保留报告,重跑使用新报告路径并重新登录,结束退出自身会话,不清理业务样本。离线合同为同目录 `selfoperated-test-env-acceptance.test.mjs`。
@@ -0,0 +1,60 @@
-- 目标数据库:baiyue_lastleg;仅增加结构,不修改既有业务数据,可重复执行。
USE baiyue_lastleg;
SET @pickup_column_sql = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_fleet_trip' AND column_name='trip_purpose')=0,
'ALTER TABLE ll_fleet_trip ADD COLUMN trip_purpose VARCHAR(16) NOT NULL DEFAULT ''DELIVERY'' COMMENT ''DELIVERY出库派送 PICKUP上门取货入仓''',
'SELECT 1');
PREPARE pickup_column_stmt FROM @pickup_column_sql;
EXECUTE pickup_column_stmt;
DEALLOCATE PREPARE pickup_column_stmt;
CREATE TABLE IF NOT EXISTS ll_fleet_pickup_plan (
trip_no VARCHAR(64) NOT NULL COMMENT '现有车辆车次号',
request_hash VARCHAR(64) NOT NULL COMMENT '创建来源与计划摘要',
PRIMARY KEY(trip_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运取货计划';
CREATE TABLE IF NOT EXISTS ll_fleet_pickup_task (
transfer_order_id BIGINT NOT NULL COMMENT '原仓储转运单ID,一票唯一',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键',
transfer_no VARCHAR(64) NOT NULL COMMENT '原仓储转运单号',
trip_no VARCHAR(64) NULL COMMENT '当前取货车次,解除后为空',
status VARCHAR(32) NOT NULL COMMENT 'WAITING待安排 ASSIGNED已安排 PICKED已取货 ARRIVED已送仓',
actual_packages INT NULL COMMENT '司机实提箱数,非仓库实收',
picked_at DATETIME NULL COMMENT '实际确认取货UTC时间',
arrived_at DATETIME NULL COMMENT '实际确认送仓UTC时间',
evidence_json LONGTEXT NULL COMMENT '本票已确认凭证元数据',
remark VARCHAR(1000) NULL COMMENT '取货备注',
updated_at DATETIME NOT NULL COMMENT '最近修改UTC时间',
PRIMARY KEY(transfer_order_id),
UNIQUE KEY uk_pickup_source(customer_id,transfer_no),
KEY idx_pickup_trip(trip_no,status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运逐票取货执行';
CREATE TABLE IF NOT EXISTS ll_fleet_pickup_command (
trip_no VARCHAR(64) NOT NULL COMMENT '取货车次号',
request_key VARCHAR(128) NOT NULL COMMENT '成功命令幂等键',
request_hash VARCHAR(64) NOT NULL COMMENT '命令动作与内容摘要',
PRIMARY KEY(trip_no,request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货命令幂等记录';
CREATE TABLE IF NOT EXISTS ll_fleet_pickup_upload (
transfer_order_id BIGINT NOT NULL COMMENT '凭证所属原单',
trip_no VARCHAR(64) NOT NULL COMMENT '凭证所属车次',
file_id BIGINT NOT NULL COMMENT '平台归属文件ID',
PRIMARY KEY(file_id),
KEY idx_pickup_upload_source(transfer_order_id,trip_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货上传凭证精确单据归属';
CREATE TABLE IF NOT EXISTS ll_fleet_pickup_history (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '操作流水',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键',
transfer_no VARCHAR(64) NOT NULL COMMENT '原转运单号',
trip_no VARCHAR(64) NOT NULL COMMENT '当时取货车次号',
action VARCHAR(32) NOT NULL COMMENT '安排解除取货到仓动作',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
occurred_at DATETIME NOT NULL COMMENT '操作UTC时间',
detail VARCHAR(2000) NULL COMMENT '操作说明',
PRIMARY KEY(id),
KEY idx_pickup_history_source(customer_id,transfer_no,id),
KEY idx_pickup_history_trip(trip_no,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
@@ -0,0 +1,241 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { resolve, dirname } from 'node:path'
import { createHash, randomUUID } from 'node:crypto'
// 本入口只访问显式指定的本机统一 Gateway;所有业务数据均经正式 HTTP 创建。
const gateway = String(process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, '')
if (!gateway || !['localhost', '127.0.0.1'].includes(new URL(gateway).hostname)) throw new Error('仅允许显式配置本机 Gateway')
if (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD || !process.env.LOCAL_TRANSFER_PDA_PASSWORD) throw new Error('缺少本机平台与PDA验收凭据')
const priorReport = process.env.PICKUP_ACCEPTANCE_RESUME ? JSON.parse(await readFile(resolve(process.env.PICKUP_ACCEPTANCE_RESUME), 'utf8')) : null
if (priorReport?.passed) throw new Error('本批次已验收完成,不重复执行业务;请查看原报告或创建新批次')
const marker = priorReport?.marker || `PICKUP-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}`
const output = resolve(process.env.PICKUP_ACCEPTANCE_RESUME || process.env.PICKUP_ACCEPTANCE_OUTPUT || `reports/${marker}.json`)
const report = priorReport || { marker, environment: 'LOCAL', evidence: 'SYNTHETIC_TEST_PNG_NOT_REAL_PHYSICAL_PICKUP', startedAt: new Date().toISOString(), passed: false, orders: [], trips: [], checks: [] }
const transferBase = '/api/platform/v1/last-leg/warehouse-transfers', pickupBase = '/api/platform/v1/last-leg/fleet/pickups', tripBase = '/api/platform/v1/last-leg/fleet/trips'
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=','base64')
const attemptStart = report.checks.length
report.attempts ||= []
const attempt = { startedAt: new Date().toISOString(), checkOffset: attemptStart, passed: false }
report.attempts.push(attempt)
let admin, pda
/** 每个业务请求保留真实错误码和诊断,不保存会话、密码和完整客户资料。 */
async function api(token, method, path, body, expectedCode) {
const form = body instanceof FormData
// 写入前留脱敏意图,网络结果未知时可用原请求键定位,不能当作未提交再次造单。
if (method !== 'GET' && !path.startsWith('/api/auth/')) {
report.intents ||= []
report.intents.push({ method, path, idempotencyKey: body?.idempotencyKey, payloadSha256: form ? undefined : createHash('sha256').update(JSON.stringify(body ?? null)).digest('hex'), at: new Date().toISOString() })
await save()
}
const response = await fetch(`${gateway}${path}`, { method, signal: AbortSignal.timeout(45000), headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body !== undefined && !form ? { 'Content-Type': 'application/json' } : {}) }, body: body === undefined ? undefined : form ? body : JSON.stringify(body) })
const value = await response.json().catch(() => null), code = String(value?.code)
const passed = response.status === 200 && (expectedCode ? code === expectedCode : code === '200')
report.checks.push({ method, path, code, http: response.status, expectedCode, passed, requestId: response.headers.get('x-request-id') || value?.requestId, traceId: response.headers.get('x-trace-id') || value?.traceId, message: code === '200' ? undefined : value?.message })
await save()
if (!passed) throw new Error(`${method} ${path}: ${response.status}/${code} ${value?.message || ''}`)
return expectedCode ? value : value.data
}
/** 断言必须对应服务端读回结果。 */
function check(condition, label) { report.checks.push({ label, passed: Boolean(condition) }); if (!condition) throw new Error(label) }
/** 每个创建阶段立即落检查点,失败不删除本轮已成功单据。 */
async function save() { await mkdir(dirname(output), { recursive: true }); await writeFile(output, JSON.stringify(report, null, 2)) }
/** 本地模拟资料仅验证文件归属、签名和上传链路,不冒充实际照片或驾驶证。 */
function image(name) { const body = new FormData(); body.append('file', new Blob([png], { type: 'image/png' }), `${marker}-${name}-TEST.png`); return body }
/** 所有执行操作使用当前车次版本和全新命令标识。 */
const command = (trip, reason) => ({ recordVersion: trip.trip.recordVersion, idempotencyKey: randomUUID(), reason })
/** 以既有有效客户创建本轮新业务单,不读取或推进历史订单。 */
async function order(customer, warehouse, index, method = 'WAREHOUSE_PICKUP', review = true) {
if (priorReport?.orders[index - 1]) return priorReport.orders[index - 1]
const products = ['手机硅胶保护壳', '折叠收纳盒', 'USB-C充电线', '宠物饮水器', '不锈钢保温杯', '桌面理线器']
const counts = index % 3 === 0 ? [3, 2, 1] : index % 2 === 0 ? [4, 2] : [3]
const payload = { operatingWarehouseId: warehouse.id, destinationType: 'PRIVATE', privateDestinationAddress: '100 TEST Distribution Road, Ontario, CA', privateDestinationPostalCode: '91761', privateDestinationContactName: '取货验收收件人', privateDestinationContactPhone: '0000000000',
deliveryAddress: `${1580 + index} TEST Pickup Ave, Ontario, CA 91761 (${marker})`, pickupContactName: `取货验收员${index}`, pickupContactPhone: '0000000000',
packageCount: counts.reduce((sum, value) => sum + value, 0), volumeCbm: 0, deliveryMethod: method, estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString().slice(0, 19),
estimatedShipAt: new Date(Date.now() + 172800000).toISOString().slice(0, 19), remark: `${marker} 场景${index} 本地业务接口验收单`, needRelabel: false, idempotencyKey: `${marker}-${index}`,
items: counts.map((packageCount, j) => ({ skuCode: `${marker}-${index}-${j + 1}`, productNameZh: products[(index + j) % products.length], quantity: packageCount * (8 + j * 4), packageCount, shippingMark: `${marker}-CUSTOMER-${index}-${j + 1}` })) }
let detail
if (index === 1) {
const token = await merchant(customer.id)
detail = await api(token, 'POST', '/api/merchant/v1/last-leg/warehouse-transfers', payload)
detail = await api(token, 'POST', `/api/merchant/v1/last-leg/warehouse-transfers/${detail.transferNo}/submit`)
check(detail.status === 'PENDING_REVIEW', '客户正式下单提交到待审核')
} else detail = await api(admin, 'POST', `${transferBase}?customerId=${customer.id}`, payload)
if (review) detail = await api(admin, 'POST', `${transferBase}/${detail.transferNo}/review?customerId=${customer.id}`, { decision: 'APPROVE', remark: `${marker} 本地取货验收` })
const row = { customerId: customer.id, transferNo: detail.transferNo, lastlegOrderNo: detail.lastlegOrderNo, warehouseId: warehouse.id, method, packageCount: detail.packageCount, status: detail.status, scenario: `场景${index}`, cartonMarks: detail.cartons.map(box => box.cartonMark), declaredQuantity: detail.items.reduce((sum, item) => sum + Number(item.quantity || 0), 0) }
report.orders.push(row); await save(); return row
}
/** 创建取货车次后保留单号,便于页面人工验收。 */
async function plan(sources, scenario) {
const existing = report.trips.find(row => row.scenario === scenario)
if (existing) return api(admin, 'GET', `${tripBase}/${existing.tripNo}`)
const body = { sources: sources.map(({ customerId, transferNo }) => ({ customerId, transferNo })), plannedDepartureAt: new Date(Date.now() - 300000).toISOString(), expectedDriverEndAt: new Date(Date.now() + 86400000).toISOString(), remark: `${marker} ${scenario}`, idempotencyKey: randomUUID() }
const detail = await api(admin, 'POST', pickupBase, body)
report.trips.push({ tripNo: detail.trip.tripNo, scenario, sources: body.sources, status: detail.trip.status }); await save()
const replay = await api(admin, 'POST', pickupBase, body); check(replay.trip.tripNo === detail.trip.tripNo, '安排重试保持原车次')
return detail
}
/** 创建本轮专用司机和车辆,避免占用既有业务资源。 */
async function resources(warehouse, index) {
if (report.resources?.[index - 1]) { const row = report.resources[index - 1]; return { vehicleId: row.vehicleId, driverId: row.driverId } }
const driverBase = '/api/platform/v1/last-leg/fleet/drivers'
let driver = await api(admin, 'POST', driverBase, { name: `${marker}取货司机${index}`, phone: '0000000000', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: `${marker}-${index}`, licenseType: 'Class A', licenseZone: 'UTC', licenseExpiresOn: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10), remark: `${marker} TEST licence image, local fixture only`, profile: { warehouseId: warehouse.id, vehicleModels: '26ft Box Truck', languagesAndArea: 'Ontario local TEST route' } })
driver = await api(admin, 'POST', `${driverBase}/${driver.id}/license?recordVersion=${driver.recordVersion}`, image(`driver-${index}`))
driver = await api(admin, 'POST', `${driverBase}/${driver.id}/review`, { recordVersion: driver.recordVersion, decision: 'APPROVED', reason: `${marker} 本地模拟资质审核` })
const vehicle = await api(admin, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', { plateNumber: `TEST-PU-${marker.slice(-8)}-${index}`, model: '26ft Box Truck', fuelConsumptionPer100Km: 22, mileageKm: 18000 + index * 700, driverOrDispatchCompany: `${marker}本地验收车队`, phone: '0000000000', region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: `${marker} 上门取货验收专用`, maxPalletSpaces: 12, maxLoadKg: 8000, maxVolumeCbm: 45 })
report.resources ||= []; report.resources.push({ driverId: driver.id, driverNo: driver.driverNo, vehicleId: vehicle.id, plateNumber: vehicle.plateNumber }); await save()
return { vehicleId: vehicle.id, driverId: driver.id }
}
/** 分配资源沿原接口,取货车与派送车共用占用规则。 */
async function assign(trip, resource) { if (trip.trip.vehicleId === resource.vehicleId && trip.trip.driverId === resource.driverId) return trip; return api(admin, 'POST', `${tripBase}/${trip.trip.tripNo}/assignment`, { recordVersion: trip.trip.recordVersion, ...resource, reason: `${marker} 分配取货车与司机` }) }
/** 发车使用取货链,不拣货、不生成出库单。 */
async function depart(trip) { if (trip.trip.status !== 'PLANNED') return trip; return api(admin, 'POST', `${pickupBase}/${trip.trip.tripNo}/depart`, command(trip, `${marker} 车辆已出发取货`)) }
/** 逐票取货并验证同键重放,不重复扣减或改写客户预报。 */
async function picked(trip, source, actual = source.packageCount) {
const existing = (await api(admin, 'GET', `${pickupBase}/${trip.trip.tripNo}/tasks`)).find(row => row.customerId === source.customerId && row.transferNo === source.transferNo)
if (existing && ['PICKED', 'ARRIVED'].includes(existing.status)) return { trip, file: existing.evidence[0] }
const path = `${pickupBase}/${trip.trip.tripNo}/tasks/${source.customerId}/${source.transferNo}`
const file = await api(admin, 'POST', `${path}/files`, image(source.transferNo))
const body = { recordVersion: trip.trip.recordVersion, idempotencyKey: randomUUID(), actualPackages: actual, evidenceFileIds: [file.fileId], remark: actual === source.packageCount ? '取货现场箱数已核对' : '实提与预报不同,等待仓库独立清点' }
await api(admin, 'POST', `${path}/picked`, body); await api(admin, 'POST', `${path}/picked`, body)
await api(admin, 'POST', `${path}/picked`, { ...body, actualPackages: actual + 1 }, 'A0409')
const detail = await api(admin, 'GET', `${transferBase}/${source.transferNo}?customerId=${source.customerId}`)
check(detail.packageCount === source.packageCount && detail.status === 'WAIT_PDA_RECEIVING', '实提不覆盖预报箱数或完成测量')
check(JSON.stringify(detail.cartons.map(box => box.cartonMark)) === JSON.stringify(source.cartonMarks), '取货运输保留原预报箱唛')
return { trip: await api(admin, 'GET', `${tripBase}/${trip.trip.tripNo}`), file }
}
/** 到仓确认后才允许仓库作业。 */
async function arrive(trip, source) {
if ((await api(admin, 'GET', `${pickupBase}/${trip.trip.tripNo}/tasks`)).some(row => row.customerId === source.customerId && row.transferNo === source.transferNo && row.status === 'ARRIVED')) return trip
await api(admin, 'POST', `${pickupBase}/${trip.trip.tripNo}/tasks/${source.customerId}/${source.transferNo}/arrive`, command(trip, `${marker} 货已送达入仓月台`))
return api(admin, 'GET', `${tripBase}/${trip.trip.tripNo}`)
}
/** 以平台正式代登录接口取得客户会话,仅用于本轮读回与越权测试。 */
async function merchant(customerId) {
const ticket = await api(admin, 'POST', `/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 验证客户本票取货回显与隔离` })
return (await api('', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })).accessToken
}
/** 送仓后走原 PDA 托盘、逐箱清点、照片及差异确认完整链,运输不代替仓内事实。 */
async function measure(source) {
const path = `/api/pda/v1/last-leg/warehouse-transfers/${source.transferNo}`
const current = await api(pda, 'GET', path)
if (current.status !== 'WAIT_PDA_RECEIVING') { check(current.status === 'WAIT_CUSTOMER_CONFIRMATION', '续验测量完成状态'); return }
let detail = current.pallets.length ? current : await api(pda, 'POST', `${path}/pallets/drafts`, { idempotencyKey: randomUUID() })
let pallet = detail.pallets[0]
if (pallet.status === 'DRAFT') detail = await api(pda, 'POST', `${path}/pallets/${pallet.id}/generate`, { idempotencyKey: randomUUID() })
pallet = detail.pallets.find(row => row.id === pallet.id)
if (pallet.status !== 'BOUND') detail = await api(pda, 'POST', `${path}/pallets/${pallet.id}/bind`, { idempotencyKey: randomUUID(), palletCode: pallet.palletNo })
const declared = detail.items.flatMap(item => Array.from({ length: item.packageCount }, () => ({ skuCode: item.skuCode, quantity: Number(item.quantity) / item.packageCount })))
check(declared.length === detail.cartons.length, '预报SKU箱数与固定箱唛总数一致')
const boxes = detail.cartons.map((box, index) => ({ cartonMark: box.cartonMark, transferPalletId: pallet.id, length: 40 + index, width: 30, height: 25, weight: 4 + index / 10, items: [{ ...declared[index], quantity: declared[index].quantity - (index === 0 ? 1 : 0) }] }))
if (!detail.cartons.some(row => row.countedAt)) detail = await api(pda, 'POST', `${path}/cartons/count`, { idempotencyKey: randomUUID(), forecastPackageCount: detail.packageCount, boxes })
const photo = await api(pda, 'POST', `${path}/measurement-photos/files`, image('measurement'))
await api(pda, 'POST', `${path}/palletize`, { idempotencyKey: randomUUID(), photoFileIds: [photo.fileId] }, 'A0400')
detail = await api(pda, 'POST', `${path}/palletize`, { idempotencyKey: randomUUID(), photoFileIds: [photo.fileId], discrepancyReason: '本地验收:首箱SKU实点短收1件,已复点并拍照留存' })
check(detail.status === 'WAIT_CUSTOMER_CONFIRMATION' && detail.orderStatus === 'INBOUND_COMPLETED', '取货送仓后PDA完整测量成功,进入客户确认')
check(JSON.stringify(detail.cartons.map(box => box.cartonMark)) === JSON.stringify(source.cartonMarks), '测量完成保留客户预报箱唛身份')
check(detail.items.reduce((sum, item) => sum + Number(item.quantity), 0) === source.declaredQuantity, '测量不覆盖客户SKU预报件数')
source.palletNo = pallet.palletNo
source.scenario = '取货送仓后已完成PDA测量,SKU短收1件且已填写原因'
await save()
}
try {
const login = await api('', 'POST', '/api/auth/login', { loginId: process.env.ADMIN_TEST_USERNAME, password: process.env.ADMIN_TEST_PASSWORD })
admin = login.accessToken
// 本机旧权限目录可能未初始化取消/结案/释放资源;只补正式权限资源并授权本轮专用账号。
let permissions = await api(admin, 'GET', '/api/platform/v1/iam/permissions')
for (const [suffix, name] of [['cancel', '取消车次'], ['complete', '车次货物结案'], ['release', '结束车辆司机任务']]) {
const code = `platform:fleet:trip:${suffix}`
if (!permissions.some(row => row.permissionCode === code)) {
const row = await api(admin, 'POST', '/api/platform/v1/iam/permissions', { type: 'BUTTON', code, name, parentId: permissions.find(item => item.permissionCode === 'platform:fleet:trip:list').id, httpMethod: 'POST' })
permissions.push(row)
}
}
if (!report.operatorUsername) {
const existingRole = (await api(admin, 'GET', '/api/platform/v1/iam/roles')).find(row => row.roleCode === `${marker}_OPERATOR`)
const role = existingRole || await api(admin, 'POST', '/api/platform/v1/iam/roles', { roleCode: `${marker}_OPERATOR`, roleName: `${marker}本地取货验收角色`, description: '沿当前验收账号权限增加正式车次取消、结案和释放,仅供本轮本机验收' })
const allowed = new Set([...login.authorities, 'platform:fleet:trip:cancel', 'platform:fleet:trip:complete', 'platform:fleet:trip:release'])
await api(admin, 'PUT', '/api/platform/v1/iam/roles/permissions', { roleId: role.id, permissionIds: permissions.filter(row => allowed.has(row.permissionCode)).map(row => row.id) })
const username = `pickup_op_${marker.replace(/\D/g, '')}`
await api(admin, 'POST', '/api/platform/v1/iam/users', { username, password: process.env.LOCAL_TRANSFER_PDA_PASSWORD, displayName: `${marker}本地取货验收`, phone: `18${String(Date.now()).slice(-9)}`, tenantId: '0', roleIds: [role.id], status: 'ACTIVE' })
report.operatorUsername = username; await save()
}
admin = (await api('', 'POST', '/api/auth/login', { loginId: report.operatorUsername, password: process.env.LOCAL_TRANSFER_PDA_PASSWORD })).accessToken
const tenants = await api(admin, 'GET', '/api/platform/v1/iam/tenants?page=1&size=200')
const customers = tenants.records.filter(row => row.status === 'ACTIVE').sort((a, b) => Number(!String(a.tenantName || a.name).includes('测试')) - Number(!String(b.tenantName || b.name).includes('测试')))
check(customers.length >= 2, '本地至少两名有效客户用于共车隔离验收')
const warehouses = (await api(admin, 'GET', '/api/platform/v1/warehouse-admin?page=1&size=100&warehouseType=OVERSEAS&status=ACTIVE')).records
const warehouse = warehouses.find(row => row.warehouseCode === 'US-LAX-01') || warehouses[0], otherWarehouse = warehouses.find(row => row.id !== warehouse?.id)
check(warehouse && otherWarehouse, '本地至少两个有效海外仓用于异仓拒绝验收')
report.warehouseId = warehouse.id
const roles = await api(admin, 'GET', '/api/platform/v1/iam/roles'), role = roles.find(row => row.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && row.status === 'ACTIVE')
check(role, '存在海外PDA作业角色')
const username = `pda_pickup_${marker.replace(/\D/g, '')}`
if (!report.pdaUsername) await api(admin, 'POST', '/api/platform/v1/iam/users', { username, password: process.env.LOCAL_TRANSFER_PDA_PASSWORD, displayName: `${marker}取货验收PDA`, phone: `18${String(Date.now()).slice(-9)}`, email: `${username}@example.com`, tenantId: '0', roleIds: [role.id], overseasWarehouseId: warehouse.id, status: 'ACTIVE' })
pda = (await api('', 'POST', '/api/auth/login', { loginId: username, password: process.env.LOCAL_TRANSFER_PDA_PASSWORD })).accessToken
report.pdaUsername = username
const orders = []
for (let i = 1; i <= 15; i++) orders.push(await order(customers[i % 2], warehouse, i, i >= 13 && i <= 14 ? 'CUSTOMER_DELIVERY' : 'WAREHOUSE_PICKUP', i !== 15))
const differentWarehouse = await order(customers[0], otherWarehouse, 16)
const waiting = orders[0], sharedA = orders[1], sharedB = orders[2]
waiting.scenario = '待取货,供页面手工选单'; orders[12].scenario = '客户自送,不进入取货池'; orders[13].scenario = '客户自送,不进入取货池'; orders[14].scenario = '未审核,不进入取货池'
// 候选关键字查地址,取货地址中使用本轮标识;按单号定向复验不依赖全局数据量。
const single = await api(admin, 'GET', `${pickupBase}?page=1&size=100&keyword=${waiting.transferNo}`)
check(single.records.some(row => row.transferNo === waiting.transferNo), '已审上门取货单自动进入候选')
for (const excluded of [orders[12], orders[13], orders[14]]) {
const result = await api(admin, 'GET', `${pickupBase}?page=1&size=100&keyword=${excluded.transferNo}`)
check(result.total === 0 || Number(result.total) === 0, '自送或未审核单不进入待取货池')
}
const invalid = { sources: [waiting, differentWarehouse].map(({ customerId, transferNo }) => ({ customerId, transferNo })), plannedDepartureAt: new Date().toISOString(), expectedDriverEndAt: new Date(Date.now() + 86400000).toISOString(), idempotencyKey: randomUUID() }
await api(admin, 'POST', pickupBase, invalid, 'A0400')
await api(pda, 'POST', `/api/pda/v1/last-leg/warehouse-transfers/${waiting.transferNo}/pallets/drafts`, { idempotencyKey: randomUUID() }, 'A0409')
let shared = await plan([sharedA, sharedB], '两客户多点共车取货,全流程验收')
await api(admin, 'POST', pickupBase, { ...invalid, sources: [{ customerId: sharedA.customerId, transferNo: sharedA.transferNo }], idempotencyKey: randomUUID() }, 'A0409')
const resource = await resources(warehouse, 1); shared = await assign(shared, resource)
let cancelTrip = await plan([orders[3]], '取消后原单回到取货池')
await api(admin, 'POST', `${tripBase}/${cancelTrip.trip.tripNo}/assignment`, { recordVersion: cancelTrip.trip.recordVersion, ...resource, reason: '验收同一资源不能同时占用两车' }, 'A0409')
if (cancelTrip.trip.status !== 'CANCELLED') cancelTrip = await api(admin, 'POST', `${tripBase}/${cancelTrip.trip.tripNo}/cancel`, { recordVersion: cancelTrip.trip.recordVersion, reason: `${marker} 改派验收` })
check((await api(admin, 'GET', `${pickupBase}?keyword=${orders[3].transferNo}`)).records.some(row => row.transferNo === orders[3].transferNo), '取消车次后原单可重新安排')
const replanned = await plan([orders[3], orders[4]], '取消后的重新安排,保留计划中供页面验收'); orders[3].scenario = '已改派重新安排'; orders[4].scenario = '已排车未分配车辆'
shared = await depart(shared)
if (!(await api(admin, 'GET', `${pickupBase}/${shared.trip.tripNo}/tasks`)).some(row => row.status !== 'ASSIGNED')) await api(admin, 'POST', `${pickupBase}/${shared.trip.tripNo}/complete`, command(shared, '验收未取货不能结案'), 'A0409')
await api(admin, 'POST', `${tripBase}/${shared.trip.tripNo}/cancel`, { recordVersion: shared.trip.recordVersion, reason: '验收已发车不可取消' }, 'A0409')
const pickedA = await picked(shared, sharedA, sharedA.packageCount - 1); shared = pickedA.trip
const pathB = `${pickupBase}/${shared.trip.tripNo}/tasks/${sharedB.customerId}/${sharedB.transferNo}`
if ((await api(admin, 'GET', `${pickupBase}/${shared.trip.tripNo}/tasks`)).some(row => row.transferNo === sharedB.transferNo && row.status === 'ASSIGNED')) await api(admin, 'POST', `${pathB}/picked`, { recordVersion: shared.trip.recordVersion, idempotencyKey: randomUUID(), actualPackages: sharedB.packageCount, evidenceFileIds: [pickedA.file.fileId] }, 'A0400')
const pickedB = await picked(shared, sharedB); shared = pickedB.trip
shared = await arrive(shared, sharedA); shared = await arrive(shared, sharedB)
const firstMerchant = await merchant(sharedA.customerId), secondMerchant = await merchant(sharedB.customerId)
const progress = await api(firstMerchant, 'GET', `/api/merchant/v1/last-leg/warehouse-transfers/${sharedA.transferNo}/pickup-progress`)
check(progress.status === 'ARRIVED' && progress.evidence.length === 1 && progress.tripNo === shared.trip.tripNo, '客户详情回显本票取货车次、进度和凭证')
await api(secondMerchant, 'GET', `/api/merchant/v1/last-leg/warehouse-transfers/${sharedA.transferNo}/pickup-progress`, undefined, 'A0404')
await api(secondMerchant, 'GET', `/api/merchant/v1/last-leg/warehouse-transfers/${sharedB.transferNo}/pickup-evidence/${pickedA.file.fileId}`, undefined, 'A0404')
const binary = await fetch(`${gateway}/api/merchant/v1/last-leg/warehouse-transfers/${sharedA.transferNo}/pickup-evidence/${pickedA.file.fileId}`, { headers: { Authorization: `Bearer ${firstMerchant}` } })
check(binary.status === 200 && Buffer.from(await binary.arrayBuffer()).equals(png), '本票取货凭证真实文件可下载')
await api(pda, 'POST', `/api/pda/v1/last-leg/warehouse-transfers/${sharedA.transferNo}/pallets/drafts`, { idempotencyKey: randomUUID() })
check((await api(admin, 'GET', `${transferBase}/${sharedA.transferNo}?customerId=${sharedA.customerId}`)).status === 'WAIT_PDA_RECEIVING', '送仓后PDA可开始,运输不冒充测量完成')
if (shared.trip.status !== 'COMPLETED') shared = await api(admin, 'POST', `${pickupBase}/${shared.trip.tripNo}/complete`, command(shared, '全车已送仓,结束取货运输'))
for (const resourceKind of ['VEHICLE', 'DRIVER']) if (!shared.trip[resourceKind === 'VEHICLE' ? 'vehicleRelease' : 'driverRelease']) shared = await api(admin, 'POST', `${tripBase}/${shared.trip.tripNo}/resources/release`, { ...command(shared, '实际车辆司机任务结束'), resourceKind, actualEndAt: new Date().toISOString(), endLocation: warehouse.warehouseName || '本地测试海外仓', vehicleStatus: resourceKind === 'VEHICLE' ? 'IN_SERVICE' : null })
sharedA.scenario = '已送仓并已开始PDA测量,司机实提少一箱'; sharedB.scenario = '已送仓,取货运输已结案,仓库尚未清点'
await measure(sharedB)
// 保留三类不同履约阶段,方便用户进入页面继续验收。
let onRoad = await plan([orders[5], orders[6]], '已取货未送仓供页面验收'); onRoad = await assign(onRoad, resource); onRoad = await depart(onRoad); onRoad = (await picked(onRoad, orders[5])).trip; onRoad = (await picked(onRoad, orders[6])).trip
orders[5].scenario = orders[6].scenario = '已取货未送仓'
const secondResource = await resources(warehouse, 2)
let assigned = await plan([orders[7], orders[8]], '已分配车辆司机尚未发车供页面验收'); assigned = await assign(assigned, secondResource)
orders[7].scenario = orders[8].scenario = '已分配车辆司机,尚未发车'
for (const entry of report.trips) {
const detail = await api(admin, 'GET', `${tripBase}/${entry.tripNo}`); entry.status = detail.trip.status; entry.vehiclePlate = detail.trip.vehiclePlate
}
for (const entry of report.orders) {
const detail = await api(admin, 'GET', `${transferBase}/${entry.transferNo}?customerId=${entry.customerId}`); entry.status = detail.status
const progress = await api(admin, 'GET', `${transferBase}/${entry.transferNo}/pickup-progress?customerId=${entry.customerId}`); entry.pickupStatus = progress?.status || null; entry.tripNo = progress?.tripNo || null
}
check(replanned.trip.tripNo && assigned.trip.vehicleId, '改派与已分配阶段单据持久保留')
check(report.checks.slice(attemptStart).every(row => row.passed), '本次执行所有断言通过;前次失败保留审计记录')
attempt.passed = true; attempt.completedAt = new Date().toISOString();
report.passed = true; delete report.error; report.completedAt = new Date().toISOString(); await save()
console.log(JSON.stringify({ passed: true, marker, orders: report.orders.length, trips: report.trips.length, checks: report.checks.length, report: output }))
} catch (error) { attempt.error = error.message; report.error = error.message; await save(); console.error(JSON.stringify({ passed: false, marker, error: error.message, report: output })); process.exitCode = 1 }