From e8f6bb32dc5fcb7351376e1a2e7087adf7cf8529 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 19 Oct 2026 10:23:09 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 62 ++++ .../docs/warehouse-transfer-pickup.md | 15 + .../fleet/pickup/FleetPickupMapper.java | 72 +++++ .../fleet/pickup/FleetPickupModels.java | 40 +++ .../fleet/pickup/FleetPickupRepository.java | 58 ++++ .../fleet/pickup/FleetPickupService.java | 269 ++++++++++++++++++ .../lastleg/fleet/pickup/FleetPickupTask.java | 22 ++ .../pickup/PlatformFleetPickupController.java | 81 ++++++ .../TransferPickupProgressController.java | 55 ++++ .../fleet/trip/FleetBookingService.java | 3 +- .../fleet/trip/FleetCapacityOfferService.java | 5 +- .../fleet/trip/FleetResourceMapper.java | 5 + .../lastleg/fleet/trip/FleetTripEntity.java | 2 + .../fleet/trip/FleetTripExecutionService.java | 2 + .../lastleg/fleet/trip/FleetTripModels.java | 7 +- .../fleet/trip/FleetTripRepository.java | 59 ++++ .../lastleg/fleet/trip/FleetTripService.java | 157 +++++++--- .../trip/PlatformFleetTripController.java | 11 + .../service/WarehouseTransferService.java | 10 +- .../src/main/resources/db/lastleg.sql | 54 ++++ .../openapi/fleet-transfer-pickup-v1.yaml | 159 +++++++++++ .../resources/openapi/fleet-trips-v1.yaml | 31 ++ .../fleet/pickup/FleetPickupMapperTest.java | 42 +++ .../fleet/pickup/FleetPickupServiceTest.java | 106 +++++++ .../fleet/trip/FleetTripPersistenceTest.java | 23 +- .../WarehouseTransferCartonCountTest.java | 3 + .../service/WarehouseTransferServiceTest.java | 3 + scripts/README.md | 4 + scripts/db/fleet-transfer-pickup-20261017.sql | 60 ++++ scripts/local-transfer-pickup-acceptance.mjs | 241 ++++++++++++++++ 30 files changed, 1612 insertions(+), 49 deletions(-) create mode 100644 lastleg-service/docs/warehouse-transfer-pickup.md create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupTask.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PlatformFleetPickupController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/TransferPickupProgressController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripRepository.java create mode 100644 lastleg-service/src/main/resources/openapi/fleet-transfer-pickup-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java create mode 100644 scripts/db/fleet-transfer-pickup-20261017.sql create mode 100644 scripts/local-transfer-pickup-acceptance.mjs diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index d58001da3..79598ecf6 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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='取货不可变操作历史'; diff --git a/lastleg-service/docs/warehouse-transfer-pickup.md b/lastleg-service/docs/warehouse-transfer-pickup.md new file mode 100644 index 000000000..57acd95b8 --- /dev/null +++ b/lastleg-service/docs/warehouse-transfer-pickup.md @@ -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验收与远端部署、真实页面视觉验收分别记录。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java new file mode 100644 index 000000000..161a4281d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java @@ -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 { + 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 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 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); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupModels.java new file mode 100644 index 000000000..5c9544cfb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupModels.java @@ -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) { } + public record Evidence(String fileId, String fileName, String contentType, long sizeBytes) { } + public record History(String action, String tripNo, String operatorId, Instant occurredAt, String detail) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java new file mode 100644 index 000000000..70eb1ebd5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java @@ -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 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 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; } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java new file mode 100644 index 000000000..f454ebfb4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java @@ -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 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(); + 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(); + 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 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 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 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 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 read(String value,TypeReference 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); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupTask.java new file mode 100644 index 000000000..99895c641 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupTask.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PlatformFleetPickupController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PlatformFleetPickupController.java new file mode 100644 index 000000000..a636bbd01 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PlatformFleetPickupController.java @@ -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> 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 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> 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 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 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> 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> 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 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(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/TransferPickupProgressController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/TransferPickupProgressController.java new file mode 100644 index 000000000..44102cadc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/TransferPickupProgressController.java @@ -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 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 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 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 platformEvidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String transferNo,@PathVariable long fileId,@RequestParam String customerId) { + platform(user);return download(customerId,transferNo,fileId); + } + /** 文件不进入浏览器公共缓存,文件名由调用方已授权元数据提供。 */ + private ResponseEntity 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,"仅平台可以查询"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java index fde620dc2..b7f6a252d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java @@ -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);}} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java index becab1ea6..ca291fe1a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java @@ -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; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java index ea620ef7f..dccb04145 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java @@ -5,6 +5,11 @@ import java.util.List; @Mapper public interface FleetResourceMapper { + /** 一次读取当前候选的实际占用,不按车次计划状态推断空闲。 */ + @Select({""}) + List candidates(@Param("kind") String kind, @Param("ids") List 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") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripEntity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripEntity.java index 0dacd3636..0f3919276 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripEntity.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripEntity.java @@ -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; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java index 39481891c..1c377d3e0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java @@ -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("仅计划中的车次允许此操作"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java index 1c036eab9..24ae4d6f6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java @@ -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 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 dispatches, ReviewState loadingReview, List previousDispatches,String appointmentHistoryUnavailableReason) { } public record AuditSnapshot(Response trip, List dispatches, FleetExecutionModels.ConfirmedLoad confirmedLoad, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripRepository.java new file mode 100644 index 000000000..cf51f9ffb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripRepository.java @@ -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 pageTrips(Page page,Wrapper 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 filter) { return trips.update(row,filter); } + /** 读取车次当前排车成员。 */ + public List 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 filter) { return bindings.update(null,filter); } + /** 分页读取不可变车次历史。 */ + public Page pageHistory(Page page,Wrapper filter) { return histories.selectPage(page,filter); } + /** 读取原成员绑定历史。 */ + public List 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 resourceOccupancy(String kind,List 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); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java index 967680592..5d44b4731 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java @@ -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 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 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(); + 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 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 history(String tripNo, int page, int size) { var trip = required(tripNo); - var result = histories.selectPage(new Page<>(page, size), new LambdaQueryWrapper() + var result = repository.pageHistory(new Page<>(page, size), new LambdaQueryWrapper() .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 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 dispatches,String unavailableReason) { } + /** 读取预约工作批次的历史排车引用,保留历史不可用原因。 */ private AppointmentHistory previousDispatches(FleetTripEntity trip,List 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>(); @@ -338,13 +396,16 @@ public class FleetTripService { } return new AppointmentHistory(List.copyOf(previous),unavailableReason); } + /** 构造稳定预约批次关联键,避免将同名记录误合并。 */ private static List 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 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 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() + if (Set.of("ASSIGN", "PLAN").contains(action)) repository.updateBindings(new LambdaUpdateWrapper() .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().eq(FleetTripEntity::getId, trip.getId()).eq(FleetTripEntity::getRecordVersion, version)) != 1) + if (repository.updateTrip(trip, new LambdaUpdateWrapper().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 read(String value, Class 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); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java index de7d3c019..21cec563c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java @@ -57,6 +57,17 @@ public class PlatformFleetTripController { public ApiResponse 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> 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 assign(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Assign request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index 6b5984cb3..534792f67 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -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 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"); } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 1ee3b0e5d..8c824d878 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -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='取货不可变操作历史'; diff --git a/lastleg-service/src/main/resources/openapi/fleet-transfer-pickup-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-transfer-pickup-v1.yaml new file mode 100644 index 000000000..39b1a15f8 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-transfer-pickup-v1.yaml @@ -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} diff --git a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml index b0835f780..0138397ec 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml @@ -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} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java new file mode 100644 index 000000000..1a6a7af91 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java @@ -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"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java new file mode 100644 index 000000000..04c6342e5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java @@ -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 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; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java index bc0abf03f..a08940bff 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java @@ -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); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java index 4bf3aaddb..eeb0558c9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java @@ -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); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index 8ea7bbb47..9d5867743 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -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); diff --git a/scripts/README.md b/scripts/README.md index a78fb315a..23eea8f66 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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-/`;失败保留报告和业务记录,不自动重发未知结果、删除、重置或执行 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`。 diff --git a/scripts/db/fleet-transfer-pickup-20261017.sql b/scripts/db/fleet-transfer-pickup-20261017.sql new file mode 100644 index 000000000..760a50379 --- /dev/null +++ b/scripts/db/fleet-transfer-pickup-20261017.sql @@ -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='取货不可变操作历史'; diff --git a/scripts/local-transfer-pickup-acceptance.mjs b/scripts/local-transfer-pickup-acceptance.mjs new file mode 100644 index 000000000..35f68c727 --- /dev/null +++ b/scripts/local-transfer-pickup-acceptance.mjs @@ -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 }