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