feat(dispatch): 从当前排车提供内部散板需求池

This commit is contained in:
2026-10-21 18:50:56 +08:00
parent 632d35faaf
commit e2ad7857b4
27 changed files with 302 additions and 24 deletions
+2
View File
@@ -52,6 +52,8 @@
换标直接出库的实物体积快照在 `ll_outbound_order.volume_cbm`、`ll_outbound_pallet_item.volume_cbm`、`ll_delivery_order.total_volume_cbm` 三列均允许 NULL,表示来源尚未确认;已有实盘尺寸按箱计算,不能用默认 0 代替未知,也不按 SKU 件数重复累计。两份初始化基线同步此定义,已有 TEST 只能在核对结构和授权后精确调整这三列,不重放空库文件或回填历史行。 换标直接出库的实物体积快照在 `ll_outbound_order.volume_cbm`、`ll_outbound_pallet_item.volume_cbm`、`ll_delivery_order.total_volume_cbm` 三列均允许 NULL,表示来源尚未确认;已有实盘尺寸按箱计算,不能用默认 0 代替未知,也不按 SKU 件数重复累计。两份初始化基线同步此定义,已有 TEST 只能在核对结构和授权后精确调整这三列,不重放空库文件或回填历史行。
排车内部散板需求发布选项由 `lastleg-service/src/main/resources/db/dispatch-capacity-demand.sql` 提供精确结构变更:仅向 `ll_dispatch_order` 新增默认勾选的 `publish_looseboard_demand` 列,Lastleg与海外空库基线同步。该文件不是自动启动脚本;执行须单独授权,不能重放完整空库文件,不新增业务数据回填或清理。实际执行与部署状态仍归测试环境主契约。
## 唯一入口与区域边界 ## 唯一入口与区域边界
V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving.sql` 提供四表:独立收货预约、当前/历史客户成员、待确认变化和不可变覆盖版本;同步 Lastleg 与海外空库基线。有效外部约号唯一性归收货预约的目的仓与约号,批次头表不再独立占用约号。部署前必须核对四表及旧头表唯一索引;本批只准备基线,不提供测试存量回填或自动重置。旧 `reset-test-fba-appointments.sql` 已拒绝存在这些新表的环境,不能用于重放 V13 初始化。实际 SQL 与部署须逐项授权。 V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving.sql` 提供四表:独立收货预约、当前/历史客户成员、待确认变化和不可变覆盖版本;同步 Lastleg 与海外空库基线。有效外部约号唯一性归收货预约的目的仓与约号,批次头表不再独立占用约号。部署前必须核对四表及旧头表唯一索引;本批只准备基线,不提供测试存量回填或自动重置。旧 `reset-test-fba-appointments.sql` 已拒绝存在这些新表的环境,不能用于重放 V13 初始化。实际 SQL 与部署须逐项授权。
@@ -417,6 +417,7 @@ CREATE TABLE ll_dispatch_order (
id BIGINT NOT NULL COMMENT '整车排车主单内部主键', id BIGINT NOT NULL COMMENT '整车排车主单内部主键',
dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号', dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号',
task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车', task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车',
publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否向内部散板需求池发布余位',
trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID', trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID',
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID',
overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照', overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照',
+1
View File
@@ -56,6 +56,7 @@
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/` | | 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/` |
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` | | PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` |
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` | | 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` |
| 内部散板需求只读来源 | `dispatch/service/DispatchCapacityDemandService.java`、`DispatchQueryController.java`的`/dispatches/capacity-demands`;默认发布字段随原排车创建/编辑保存,当前业务口径见拼车主契约,不恢复旧车次人工发布入口 |
| 平台排车查询、整车四块详情、审核与实载 | `dispatch/controller/DispatchQueryController.java`、`DispatchAggregateController.java`;公开契约`dispatch/port/`,业务规则见`../../docs/海外仓业务/排车列表统一作业改造契约.md` | | 平台排车查询、整车四块详情、审核与实载 | `dispatch/controller/DispatchQueryController.java`、`DispatchAggregateController.java`;公开契约`dispatch/port/`,业务规则见`../../docs/海外仓业务/排车列表统一作业改造契约.md` |
| 草稿、双端装车发运 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`;自动发运编排`dispatch/service/DispatchLoadingDepartureService.java` | | 草稿、双端装车发运 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`;自动发运编排`dispatch/service/DispatchLoadingDepartureService.java` |
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` |
@@ -36,6 +36,7 @@ import java.nio.charset.StandardCharsets;
public class DispatchQueryController { public class DispatchQueryController {
@Resource private DispatchAggregateService aggregateService; @Resource private DispatchAggregateService aggregateService;
@Resource private DispatchBolPrintService printService; @Resource private DispatchBolPrintService printService;
@Resource private com.baiyue.lastleg.dispatch.service.DispatchCapacityDemandService capacityDemands;
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
@@ -47,6 +48,14 @@ public class DispatchQueryController {
return ApiResponse.success(aggregateService.page(DispatchActorFactory.platform(user),query)); return ApiResponse.success(aggregateService.page(DispatchActorFactory.platform(user),query));
} }
/** 内部需求池沿独立只读权限,不授予销售整车客户及司机资料读取权限。 */
@GetMapping("/capacity-demands")
@PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
public ApiResponse<PageResult<com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Demand>> capacityDemands(
@AuthenticationPrincipal AuthenticatedUser user, @Valid com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Query query) {
requirePlatform(user); return ApiResponse.success(capacityDemands.page(query));
}
@GetMapping("/{dispatchNo}") @GetMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
@Operation(summary = "Get dispatch details and all pallet destinations") @Operation(summary = "Get dispatch details and all pallet destinations")
@@ -9,6 +9,8 @@ public class DispatchAggregate {
private Long id; private Long id;
private String dispatchNo; private String dispatchNo;
private String taskType; private String taskType;
/** 是否向内部散板需求池发布本车余位。 */
private Boolean publishLooseboardDemand = true;
private Long tripId; private Long tripId;
private String overseasWarehouseId; private String overseasWarehouseId;
private String overseasWarehouseCode; private String overseasWarehouseCode;
@@ -40,12 +40,12 @@ public interface DispatchAggregateMapper {
/** 首次创建整车所有权及初始版本,不写customer_id。 */ /** 首次创建整车所有权及初始版本,不写customer_id。 */
@Insert(""" @Insert("""
INSERT INTO ll_dispatch_order(id,dispatch_no,task_type,trip_id,overseas_warehouse_id,overseas_warehouse_code, INSERT INTO ll_dispatch_order(id,dispatch_no,task_type,publish_looseboard_demand,trip_id,overseas_warehouse_id,overseas_warehouse_code,
overseas_warehouse_name,status,stage,record_version,cargo_revision,cargo_hash,review_state,review_round, overseas_warehouse_name,status,stage,record_version,cargo_revision,cargo_hash,review_state,review_round,
dispatch_review_json,loading_review_json,resources_json,vehicle_plate,route,planned_departure_at, dispatch_review_json,loading_review_json,resources_json,vehicle_plate,route,planned_departure_at,
estimated_arrival_at,item_count,pallet_count,package_count,picked_package_count,shipped_package_count, estimated_arrival_at,item_count,pallet_count,package_count,picked_package_count,shipped_package_count,
remark,created_by,created_at,updated_at) remark,created_by,created_at,updated_at)
VALUES(#{r.id},#{r.dispatchNo},COALESCE(#{r.taskType},'DELIVERY'),#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode}, VALUES(#{r.id},#{r.dispatchNo},COALESCE(#{r.taskType},'DELIVERY'),#{r.publishLooseboardDemand},#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode},
#{r.overseasWarehouseName},#{r.status},#{r.stage},#{r.recordVersion},#{r.cargoRevision},#{r.cargoHash}, #{r.overseasWarehouseName},#{r.status},#{r.stage},#{r.recordVersion},#{r.cargoRevision},#{r.cargoHash},
#{r.reviewState},#{r.reviewRound},#{r.dispatchReviewJson},#{r.loadingReviewJson},#{r.resourcesJson}, #{r.reviewState},#{r.reviewRound},#{r.dispatchReviewJson},#{r.loadingReviewJson},#{r.resourcesJson},
#{r.vehiclePlate},#{r.route},#{r.plannedDepartureAt},#{r.estimatedArrivalAt},#{r.itemCount},#{r.palletCount}, #{r.vehiclePlate},#{r.route},#{r.plannedDepartureAt},#{r.estimatedArrivalAt},#{r.itemCount},#{r.palletCount},
@@ -66,6 +66,17 @@ public interface DispatchAggregateMapper {
+ " ORDER BY d.created_at DESC,d.id DESC LIMIT #{q.limit} OFFSET #{q.offset}</script>") + " ORDER BY d.created_at DESC,d.id DESC LIMIT #{q.limit} OFFSET #{q.offset}</script>")
List<DispatchAggregate> page(@Param("q") Search query); List<DispatchAggregate> page(@Param("q") Search query);
/** 只读余位来源仅含勾选发布、未发车的有效派送排车,不混入取货和本土卡车。 */
@Select("""
<script>SELECT d.* FROM ll_dispatch_order d
WHERE d.publish_looseboard_demand=TRUE AND d.task_type='DELIVERY' AND d.status='ACTIVE'
AND d.departed_at IS NULL AND d.stage IN ('BEFORE_LOADING','LOADING','AFTER_LOADING')
<if test='q.origin != null'>AND d.overseas_warehouse_name LIKE CONCAT('%',#{q.origin},'%')</if>
<if test='q.destination != null'>AND d.route LIKE CONCAT('%',#{q.destination},'%')</if>
ORDER BY d.planned_departure_at,d.created_at DESC,d.id DESC</script>
""")
List<DispatchAggregate> capacityDemands(@Param("q") com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Query query);
/** 分页总数复用完全相同筛选,不能用当前页长度替代。 */ /** 分页总数复用完全相同筛选,不能用当前页长度替代。 */
@Select("<script>SELECT COUNT(*) FROM ll_dispatch_order d WHERE 1=1 " + BASE_FILTER + CURRENT_FILTER + "</script>") @Select("<script>SELECT COUNT(*) FROM ll_dispatch_order d WHERE 1=1 " + BASE_FILTER + CURRENT_FILTER + "</script>")
long count(@Param("q") Search query); long count(@Param("q") Search query);
@@ -91,7 +102,7 @@ public interface DispatchAggregateMapper {
/** 父锁内按公共版本保存状态及审核依据,显式清空旧依据而不依赖实体非空更新策略。 */ /** 父锁内按公共版本保存状态及审核依据,显式清空旧依据而不依赖实体非空更新策略。 */
@Update(""" @Update("""
UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion}, UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion},publish_looseboard_demand=#{r.publishLooseboardDemand},
review_state=#{r.reviewState},review_round=#{r.reviewRound},review_basis_hash=#{r.reviewBasisHash}, review_state=#{r.reviewState},review_round=#{r.reviewRound},review_basis_hash=#{r.reviewBasisHash},
dispatch_review_json=#{r.dispatchReviewJson},loading_review_json=#{r.loadingReviewJson}, dispatch_review_json=#{r.dispatchReviewJson},loading_review_json=#{r.loadingReviewJson},
exceptions_json=#{r.exceptionsJson},cargo_hash=#{r.cargoHash},cargo_revision=#{r.cargoRevision},resources_json=#{r.resourcesJson},vehicle_plate=#{r.vehiclePlate},planned_departure_at=#{r.plannedDepartureAt}, exceptions_json=#{r.exceptionsJson},cargo_hash=#{r.cargoHash},cargo_revision=#{r.cargoRevision},resources_json=#{r.resourcesJson},vehicle_plate=#{r.vehiclePlate},planned_departure_at=#{r.plannedDepartureAt},
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.dispatch.port;
import java.math.BigDecimal;
import java.time.Instant;
/** 内部余位池仅公开本车运输摘要,不包含客户货物或司机私人资料。 */
public final class DispatchCapacityDemands {
/** 只承载只读查询模型。 */
private DispatchCapacityDemands() { }
public record Query(Integer page, Integer size, String origin, String destination) {
/** 查询范围有界,默认每页二十条。 */
public Query {
page = page == null ? 1 : page; size = size == null ? 20 : size;
if (page < 1 || size < 1 || size > 100) throw new IllegalArgumentException("余位需求分页参数无效");
origin = origin == null || origin.isBlank() ? null : origin.trim();
destination = destination == null || destination.isBlank() ? null : destination.trim();
}
}
public record Demand(String dispatchNo, String warehouseName, String route, Instant plannedDepartureAt,
Integer vehiclePalletSpaces, Integer scheduledPalletCount, Integer remainingPalletSpaces,
BigDecimal remainingWeightKg, BigDecimal remainingVolumeCbm, boolean capacityKnown, String capacityReason) { }
}
@@ -23,13 +23,16 @@ public final class DispatchCommands {
/** 空cargo表示先建空单;实际来源及客户必须由服务端解析。 */ /** 空cargo表示先建空单;实际来源及客户必须由服务端解析。 */
public record Create(String idempotencyKey, String warehouseId, Vehicle vehicle, Driver driver, public record Create(String idempotencyKey, String warehouseId, Vehicle vehicle, Driver driver,
Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark, Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark,
List<CargoSelection> cargo, List<BatchAllocation> batchAllocations) { } List<CargoSelection> cargo, List<BatchAllocation> batchAllocations, Boolean publishLooseboardDemand) {
/** 新建排车默认发布内部余位,显式取消时保持不发布。 */
public Create { publishLooseboardDemand = publishLooseboardDemand == null ? true : publishLooseboardDemand; }
}
public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver, public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver,
Instant plannedDepartureAt, Instant estimatedArrivalAt, Instant plannedDepartureAt, Instant estimatedArrivalAt,
String remark) { String remark, Boolean publishLooseboardDemand) {
/** 缺版本与有效版本零必须区分。 */ /** 缺版本与有效版本零必须区分。 */
public Update { recordVersion = requireVersion(recordVersion); } public Update { recordVersion = requireVersion(recordVersion); if (publishLooseboardDemand == null) throw new IllegalArgumentException("请明确是否发布散板需求"); }
} }
public record AddCargo(Long recordVersion, String idempotencyKey, List<CargoSelection> cargo, public record AddCargo(Long recordVersion, String idempotencyKey, List<CargoSelection> cargo,
@@ -64,7 +64,7 @@ public final class DispatchViews {
ReviewState reviewState, Reviews reviews, Counts counts, ReviewState reviewState, Reviews reviews, Counts counts,
boolean pickingReconciled, Map<Action, Gate> actions, boolean pickingReconciled, Map<Action, Gate> actions,
VehicleInfo vehicleInfo, DispatchInfo dispatchInfo, Documents documents, VehicleInfo vehicleInfo, DispatchInfo dispatchInfo, Documents documents,
List<OrderDetail> orderDetails) { } List<OrderDetail> orderDetails, boolean publishLooseboardDemand) { }
public record PdaTask(String taskNo, String dispatchNo, String warehouseId, Status status, public record PdaTask(String taskNo, String dispatchNo, String warehouseId, Status status,
Stage stage, long recordVersion, Reviews reviews, Resources resources, Counts counts, Stage stage, long recordVersion, Reviews reviews, Resources resources, Counts counts,
@@ -34,6 +34,8 @@ public class DispatchAggregateRepository {
public DispatchAggregate lock(String number) { return mapper.lock(number); } public DispatchAggregate lock(String number) { return mapper.lock(number); }
/** 整车分页。 */ /** 整车分页。 */
public List<DispatchAggregate> page(DispatchAggregateMapper.Search query) { return mapper.page(query); } public List<DispatchAggregate> page(DispatchAggregateMapper.Search query) { return mapper.page(query); }
/** 一次读取当前有效的内部余位来源,服务计算满载筛选后再分页。 */
public List<DispatchAggregate> capacityDemands(com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Query query) { return mapper.capacityDemands(query); }
/** 整车分页总数。 */ /** 整车分页总数。 */
public long count(DispatchAggregateMapper.Search query) { return mapper.count(query); } public long count(DispatchAggregateMapper.Search query) { return mapper.count(query); }
/** 全搜索基集的阶段和审核分布。 */ /** 全搜索基集的阶段和审核分布。 */
@@ -305,6 +305,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
row.setId(id); row.setDispatchNo(number); row.setTripId(shell.tripId()); row.setId(id); row.setDispatchNo(number); row.setTripId(shell.tripId());
row.setOverseasWarehouseId(shell.warehouseId()); row.setOverseasWarehouseCode(shell.warehouseCode()); row.setOverseasWarehouseName(shell.warehouseName()); row.setOverseasWarehouseId(shell.warehouseId()); row.setOverseasWarehouseCode(shell.warehouseCode()); row.setOverseasWarehouseName(shell.warehouseName());
row.setStatus(Status.ACTIVE.name()); row.setStage(Stage.BEFORE_LOADING.name()); row.setRecordVersion(0L); row.setStatus(Status.ACTIVE.name()); row.setStage(Stage.BEFORE_LOADING.name()); row.setRecordVersion(0L);
row.setPublishLooseboardDemand(request.publishLooseboardDemand());
row.setCargoRevision(0L); row.setCargoHash(hashes.source(List.of())); row.setReviewState(ReviewState.DRAFT.name()); row.setReviewRound(0L); row.setCargoRevision(0L); row.setCargoHash(hashes.source(List.of())); row.setReviewState(ReviewState.DRAFT.name()); row.setReviewRound(0L);
row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0))); row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0)));
var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds(); var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds();
@@ -393,6 +394,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver())); var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver()));
row.setResourcesJson(write(resources(actual,photos)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate()); row.setResourcesJson(write(resources(actual,photos)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark()); row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark());
row.setPublishLooseboardDemand(request.publishLooseboardDemand());
if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改"); if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改");
fleet.saveDispatchResources(number,actor.operatorId(),actual,request.plannedDepartureAt(),request.remark()); fleet.saveDispatchResources(number,actor.operatorId(),actual,request.plannedDepartureAt(),request.remark());
return saveCommand(row,actor,identity,request.remark()); return saveCommand(row,actor,identity,request.remark());
@@ -754,7 +756,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
return new DispatchViews.Detail(number,row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), return new DispatchViews.Detail(number,row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()), reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()),
new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs,exceptionRecords(row)), new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs,exceptionRecords(row)),
documents,orderDetails(cargo)); documents,orderDetails(cargo),Boolean.TRUE.equals(row.getPublishLooseboardDemand()));
} }
/** 恢复动作结果前重新核验当前单据范围和原动作权限。 */ /** 恢复动作结果前重新核验当前单据范围和原动作权限。 */
@@ -0,0 +1,66 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.annotation.Isolation;
import java.math.BigDecimal;
import java.time.ZoneOffset;
import java.util.Objects;
/** 从日常排车实时投影内部余位,不创建第二套车次、发布单或占位。 */
@Service
public class DispatchCapacityDemandService {
private final DispatchAggregateRepository repository;
private final ObjectMapper json;
/** 复用排车仓储和正式装载快照解析。 */
public DispatchCapacityDemandService(DispatchAggregateRepository repository, ObjectMapper json) {
this.repository = repository; this.json = json;
}
/** 当前候选一次查询;已知满载先过滤,随后分页,保证总数与结果一致。 */
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public PageResult<DispatchCapacityDemands.Demand> page(DispatchCapacityDemands.Query query) {
var values = repository.capacityDemands(query).stream().map(this::project).filter(Objects::nonNull).toList();
long start = Math.min((long)(query.page()-1)*query.size(), values.size());
int end = (int)Math.min(start+query.size(), values.size());
return new PageResult<>(values.subList((int)start,end), query.page(), query.size(), values.size(), (values.size()+query.size()-1)/query.size());
}
/** 实托数不等于标准托位;缺少可靠装载证据时明确未知,不能拿三十托或零值推算。 */
private DispatchCapacityDemands.Demand project(DispatchAggregate row) {
Integer slots = null, remaining = null; BigDecimal weight = null, volume = null;
String reason = "占用标准托位、重量及体积待确认";
try {
var resources = row.getResourcesJson() == null ? null : json.readValue(row.getResourcesJson(), DispatchContracts.Resources.class);
var cap = resources == null || resources.vehicle() == null ? null : resources.vehicle().capacity();
if (cap != null) slots = cap.palletSpaces();
var load = row.getLoadingConfirmationJson() == null ? null : json.readValue(row.getLoadingConfirmationJson(), DispatchContracts.ConfirmedLoad.class);
if (cap == null || slots == null || slots <= 0 || !positive(cap.maxLoadKg()) || !positive(cap.maxVolumeCbm())) reason = "车辆真实容量待确认";
if (cap != null && load != null && load.pallets() != null && !load.pallets().isEmpty()
&& load.pallets().stream().allMatch(p -> p.warehousePalletId()!=null && p.palletSpaces()>0)
&& load.pallets().stream().map(DispatchContracts.LoadingMeasurement::warehousePalletId).distinct().count()==load.pallets().size()) {
// 首版均在起运仓上车,后续仅卸货;任一已知维度满载都退出需求池。
long occupied = load.pallets().stream().mapToLong(DispatchContracts.LoadingMeasurement::palletSpaces).sum();
if (slots != null && slots > 0) { if (occupied >= slots) return null; remaining = Math.toIntExact(slots-occupied); }
if (positive(cap.maxLoadKg()) && load.pallets().stream().allMatch(p -> positive(p.weightKg()))) {
var used = load.pallets().stream().map(DispatchContracts.LoadingMeasurement::weightKg).reduce(BigDecimal.ZERO,BigDecimal::add);
if (used.compareTo(cap.maxLoadKg())>=0) return null; weight=cap.maxLoadKg().subtract(used);
}
if (positive(cap.maxVolumeCbm()) && load.pallets().stream().allMatch(p -> positive(p.volumeCbm()))) {
var used = load.pallets().stream().map(DispatchContracts.LoadingMeasurement::volumeCbm).reduce(BigDecimal.ZERO,BigDecimal::add);
if (used.compareTo(cap.maxVolumeCbm())>=0) return null; volume=cap.maxVolumeCbm().subtract(used);
}
if (remaining!=null && weight!=null && volume!=null) reason=null;
}
} catch (JsonProcessingException invalid) { reason="车辆或装载资料待核对"; }
return new DispatchCapacityDemands.Demand(row.getDispatchNo(),row.getOverseasWarehouseName(),row.getRoute(),
row.getPlannedDepartureAt()==null?null:row.getPlannedDepartureAt().toInstant(ZoneOffset.UTC),slots,row.getPalletCount(),remaining,weight,volume,reason==null,reason);
}
/** 三维容量均须有真实正值。 */
private static boolean positive(BigDecimal value) { return value!=null && value.signum()>0; }
}
@@ -0,0 +1,3 @@
-- 本次仅新增排车内部余位发布选项;执行需单独授权,不回填、清理或重置业务数据。
ALTER TABLE ll_dispatch_order
ADD COLUMN publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否向内部散板需求池发布余位' AFTER task_type;
@@ -1895,6 +1895,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_order (
id BIGINT NOT NULL COMMENT '整车排车主单内部主键', id BIGINT NOT NULL COMMENT '整车排车主单内部主键',
dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号', dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号',
task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车', task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车',
publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否向内部散板需求池发布余位',
trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID', trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID',
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID',
overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照', overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照',
@@ -4,6 +4,35 @@ info:
description: 派送资料完整契约见同目录 delivery-documents-v1.yaml;派送列表/详情行返回 bolNo、documentSummary(三类状态与x/3),状态仍使用 version 和 allowedActions。 description: 派送资料完整契约见同目录 delivery-documents-v1.yaml;派送列表/详情行返回 bolNo、documentSummary(三类状态与x/3),状态仍使用 version 和 allowedActions。
version: 1.0.0 version: 1.0.0
paths: paths:
/api/platform/v1/last-leg/dispatches/capacity-demands:
get:
summary: 读取有效派送排车的内部散板余位需求
description: 仅平台内部capacity-offer:list或manage可读;返回运输摘要,不返回客户、司机私人资料。实时排除未勾选、取货、本土卡车、已发车、取消及已知满载排车;缺测余位明确未知。
operationId: listDispatchCapacityDemands
security: [{ bearerAuth: [] }]
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: origin, in: query, schema: { type: string } }
- { name: destination, in: query, schema: { type: string } }
responses:
'200':
description: 统一业务响应,data为包含records、page、size、total和pages的分页结果。
content:
application/json:
schema:
type: object
properties:
code: { type: string }
data:
type: object
properties:
records: { type: array, items: { $ref: '#/components/schemas/DispatchCapacityDemand' } }
page: { oneOf: [{ type: string, pattern: '^[0-9]+$' }, { type: integer }] }
size: { oneOf: [{ type: string, pattern: '^[0-9]+$' }, { type: integer }] }
total: { oneOf: [{ type: string, pattern: '^[0-9]+$' }, { type: integer }] }
pages: { oneOf: [{ type: string, pattern: '^[0-9]+$' }, { type: integer }] }
/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/increase-forecast-cartons: /api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/increase-forecast-cartons:
post: post:
summary: 测量完成前增加预报箱数并补充箱唛 summary: 测量完成前增加预报箱数并补充箱唛
@@ -3961,6 +3990,10 @@ components:
type: object type: object
x-java-record: DispatchCommands.Update x-java-record: DispatchCommands.Update
properties: properties:
publishLooseboardDemand:
type: boolean
description: 是否向内部散板需求池发布本车余位;新建默认勾选。
recordVersion: recordVersion:
oneOf: oneOf:
- type: string - type: string
@@ -3990,6 +4023,7 @@ components:
type: string type: string
nullable: true nullable: true
required: required:
- publishLooseboardDemand
- recordVersion - recordVersion
- idempotencyKey - idempotencyKey
DeliveryStatusChange: DeliveryStatusChange:
@@ -5783,10 +5817,31 @@ components:
required: required:
- sourceType - sourceType
- sourceVersion - sourceVersion
DispatchCapacityDemand:
type: object
x-java-record: DispatchCapacityDemands.Demand
properties:
dispatchNo: { type: string }
warehouseName: { type: string, nullable: true }
route: { type: string, nullable: true }
plannedDepartureAt: { type: string, format: date-time, nullable: true }
vehiclePalletSpaces: { type: integer, nullable: true }
scheduledPalletCount: { type: integer, nullable: true }
remainingPalletSpaces: { type: integer, nullable: true }
remainingWeightKg: { type: number, nullable: true }
remainingVolumeCbm: { type: number, nullable: true }
capacityKnown: { type: boolean }
capacityReason: { type: string, nullable: true }
required: [dispatchNo, capacityKnown]
DispatchCreate: DispatchCreate:
type: object type: object
x-java-record: DispatchCommands.Create x-java-record: DispatchCommands.Create
properties: properties:
publishLooseboardDemand:
type: boolean
description: 是否向内部散板需求池发布本车余位;新建默认勾选。
default: true
idempotencyKey: idempotencyKey:
type: string type: string
warehouseId: warehouseId:
@@ -6475,6 +6530,10 @@ components:
type: object type: object
x-java-record: DispatchViews.Detail x-java-record: DispatchViews.Detail
properties: properties:
publishLooseboardDemand:
type: boolean
description: 是否向内部散板需求池发布本车余位;新建默认勾选。
dispatchNo: dispatchNo:
type: string type: string
nullable: true nullable: true
@@ -6530,6 +6589,7 @@ components:
$ref: '#/components/schemas/DispatchOrderDetail' $ref: '#/components/schemas/DispatchOrderDetail'
nullable: true nullable: true
required: required:
- publishLooseboardDemand
- dispatchNo - dispatchNo
- status - status
- stage - stage
@@ -123,7 +123,7 @@ class PlatformDispatchOutboundContractTest {
assertTrue(Arrays.stream(DispatchController.class.getDeclaredMethods()) assertTrue(Arrays.stream(DispatchController.class.getDeclaredMethods())
.noneMatch(method -> Set.of("depart", "cancel").contains(method.getName()))); .noneMatch(method -> Set.of("depart", "cancel").contains(method.getName())));
assertEquals(Set.of("recordVersion", "idempotencyKey", "vehicle", "driver", assertEquals(Set.of("recordVersion", "idempotencyKey", "vehicle", "driver",
"plannedDepartureAt", "estimatedArrivalAt", "remark"), "plannedDepartureAt", "estimatedArrivalAt", "remark", "publishLooseboardDemand"),
Arrays.stream(DispatchCommands.Update.class.getRecordComponents()) Arrays.stream(DispatchCommands.Update.class.getRecordComponents())
.map(component -> component.getName()).collect(java.util.stream.Collectors.toSet())); .map(component -> component.getName()).collect(java.util.stream.Collectors.toSet()));
assertEquals(Set.of("recordVersion", "idempotencyKey", "reason"), assertEquals(Set.of("recordVersion", "idempotencyKey", "reason"),
@@ -38,7 +38,7 @@ class DispatchSchemaContractTest {
"ll_dispatch_pda_task",com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask.class); "ll_dispatch_pda_task",com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask.class);
for(var entry:tables.entrySet()) { for(var entry:tables.entrySet()) {
String body=table(sql,entry.getKey());var columns=new TreeSet<String>(); String body=table(sql,entry.getKey());var columns=new TreeSet<String>();
var matcher=Pattern.compile("(?:^|,)\\s*([a-z][a-z0-9_]*)\\s+(?:BIGINT|INT|CHAR|VARCHAR|DATETIME|TIMESTAMP|JSON|TEXT|LONGTEXT|DECIMAL)\\b",Pattern.MULTILINE).matcher(body); var matcher=Pattern.compile("(?:^|,)\\s*([a-z][a-z0-9_]*)\\s+(?:BIGINT|TINYINT|INT|CHAR|VARCHAR|DATETIME|TIMESTAMP|JSON|TEXT|LONGTEXT|DECIMAL)\\b",Pattern.MULTILINE).matcher(body);
while(matcher.find())columns.add(matcher.group(1)); while(matcher.find())columns.add(matcher.group(1));
var fields=new TreeSet<String>();for(var field:entry.getValue().getDeclaredFields())if(!Modifier.isStatic(field.getModifiers()) && !field.isSynthetic())fields.add(field.getName().replaceAll("([a-z0-9])([A-Z])","$1_$2").toLowerCase(Locale.ROOT)); var fields=new TreeSet<String>();for(var field:entry.getValue().getDeclaredFields())if(!Modifier.isStatic(field.getModifiers()) && !field.isSynthetic())fields.add(field.getName().replaceAll("([a-z0-9])([A-Z])","$1_$2").toLowerCase(Locale.ROOT));
assertThat(columns).as(entry.getKey()).isEqualTo(fields).doesNotContain("customer_id"); assertThat(columns).as(entry.getKey()).isEqualTo(fields).doesNotContain("customer_id");
@@ -29,6 +29,7 @@ import static org.mockito.Mockito.when;
class DispatchQueryControllerTest { class DispatchQueryControllerTest {
@Mock DispatchAggregateService service; @Mock DispatchAggregateService service;
@Mock DispatchBolPrintService printService; @Mock DispatchBolPrintService printService;
@Mock com.baiyue.lastleg.dispatch.service.DispatchCapacityDemandService capacityDemands;
private DispatchQueryController controller; private DispatchQueryController controller;
@BeforeEach @BeforeEach
@@ -36,6 +37,7 @@ class DispatchQueryControllerTest {
controller = new DispatchQueryController(); controller = new DispatchQueryController();
ReflectionTestUtils.setField(controller, "aggregateService", service); ReflectionTestUtils.setField(controller, "aggregateService", service);
ReflectionTestUtils.setField(controller, "printService", printService); ReflectionTestUtils.setField(controller, "printService", printService);
ReflectionTestUtils.setField(controller, "capacityDemands", capacityDemands);
} }
@Test @Test
@@ -72,6 +74,19 @@ class DispatchQueryControllerTest {
assertThat(print.value()).isEqualTo("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')"); assertThat(print.value()).isEqualTo("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')");
} }
/** 需求池使用独立列表权限且只允许平台身份,不向客户开放摘要来源。 */
@Test void capacityDemandPoolUsesItsOwnPermissionAndRejectsMerchantIdentity() throws Exception {
var query=new com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Query(1,20,null,null);
var permission=DispatchQueryController.class.getMethod("capacityDemands",AuthenticatedUser.class,query.getClass()).getAnnotation(PreAuthorize.class);
assertThat(permission.value()).isEqualTo("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')");
when(capacityDemands.page(query)).thenReturn(new PageResult<>(List.of(),1,20,0,0));
assertThat(controller.capacityDemands(platform(),query).data().records()).isEmpty();
var merchant=new AuthenticatedUser(7L,9L,9L,8L,"customer","MERCHANT","WEB",Set.of());
assertThatThrownBy(()->controller.capacityDemands(merchant,query)).isInstanceOfSatisfying(ResponseStatusException.class,error->assertThat(error.getStatusCode().value()).isEqualTo(403));
assertThatThrownBy(()->controller.capacityDemands(null,query)).isInstanceOfSatisfying(ResponseStatusException.class,error->assertThat(error.getStatusCode().value()).isEqualTo(401));
verify(capacityDemands).page(query);
}
private static AuthenticatedUser platform() { private static AuthenticatedUser platform() {
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of()); return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of());
} }
@@ -39,7 +39,7 @@ public final class DispatchPublicJsonContract {
rejectsMissing(requests, request, "{}"); rejectsMissing(requests, request, "{}");
rejectsMissing(requests, request, "{\"recordVersion\":null}"); rejectsMissing(requests, request, "{\"recordVersion\":null}");
rejectsMissing(requests, request, "{\"recordVersion\":-1}"); rejectsMissing(requests, request, "{\"recordVersion\":-1}");
Object zero = requests.readValue("{\"recordVersion\":0}", request); Object zero = requests.readValue(request == DispatchCommands.Update.class ? "{\"recordVersion\":0,\"publishLooseboardDemand\":true}" : "{\"recordVersion\":0}", request);
require(Long.valueOf(0).equals(request.getMethod("recordVersion").invoke(zero)), "显式零版本必须合法"); require(Long.valueOf(0).equals(request.getMethod("recordVersion").invoke(zero)), "显式零版本必须合法");
} }
rejectsMissing(requests, DispatchCommands.Update.class, rejectsMissing(requests, DispatchCommands.Update.class,
@@ -204,7 +204,7 @@ public class DispatchAggregatePersistenceTest {
/** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */ /** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */
@Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception { @Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception {
var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-edit",List.of()))); var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-edit",List.of())));
var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注"); var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注", true);
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request)); var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));
assertThat(saved.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(saved.counts().packageCount()).isZero(); assertThat(saved.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(saved.counts().packageCount()).isZero();
assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion()); assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
@@ -244,7 +244,7 @@ public class DispatchAggregatePersistenceTest {
} }
@Test void updateSavesCurrentVehiclePlanAndReplayWithoutAnyVehicleAppointmentField() throws Exception { @Test void updateSavesCurrentVehiclePlanAndReplayWithoutAnyVehicleAppointmentField() throws Exception {
var f=new Fixture();var input=f.configured("update-without-vehicle-appointment",List.of(f.source(1,"101",501,"ONT8",3)));var created=f.tx.execute(t->f.service.create(f.actor,input)); var f=new Fixture();var input=f.configured("update-without-vehicle-appointment",List.of(f.source(1,"101",501,"ONT8",3)));var created=f.tx.execute(t->f.service.create(f.actor,input));
var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划"); var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划", true);
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion()); var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
var detail=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo()));assertThat(detail.dispatchInfo().estimatedArrivalAt()).isEqualTo(arrival);assertThat(detail.documents().remark()).isEqualTo("调整本趟计划"); var detail=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo()));assertThat(detail.dispatchInfo().estimatedArrivalAt()).isEqualTo(arrival);assertThat(detail.documents().remark()).isEqualTo("调整本趟计划");
var publicJson=f.json.valueToTree(detail);assertThat(publicJson.path("dispatchInfo").has("vehicleAppointment")).isFalse();assertThat(publicJson.path("dispatchInfo").has("fbaAppointments")).isTrue(); var publicJson=f.json.valueToTree(detail);assertThat(publicJson.path("dispatchInfo").has("vehicleAppointment")).isFalse();assertThat(publicJson.path("dispatchInfo").has("fbaAppointments")).isTrue();
@@ -604,7 +604,7 @@ public class DispatchAggregatePersistenceTest {
} }
/** 空计划和资源是合法待完善事实,不补假日期或主档。 */ /** 空计划和资源是合法待完善事实,不补假日期或主档。 */
public DispatchCommands.Create create(String key,List<DispatchCommands.CargoSelection> selections) { return new DispatchCommands.Create(key,"9",null,null,null,null,null,selections,List.of()); } public DispatchCommands.Create create(String key,List<DispatchCommands.CargoSelection> selections) { return new DispatchCommands.Create(key,"9",null,null,null,null,null,selections,List.of(), true); }
/** 临时资源不创建主档;文件提供者在此夹具隔离,归属HTTP网关另行验证。 */ /** 临时资源不创建主档;文件提供者在此夹具隔离,归属HTTP网关另行验证。 */
public DispatchCommands.Create configured(String key,List<DispatchCommands.CargoSelection> sources) { public DispatchCommands.Create configured(String key,List<DispatchCommands.CargoSelection> sources) {
@@ -613,7 +613,7 @@ public class DispatchAggregatePersistenceTest {
new DispatchContracts.Capacity(15,new BigDecimal("10000"),new BigDecimal("50")),List.of(101L)), new DispatchContracts.Capacity(15,new BigDecimal("10000"),new BigDecimal("50")),List.of(101L)),
new DispatchCommands.Driver(ResourceMode.EXTERNAL,null,"测试司机","123456789", new DispatchCommands.Driver(ResourceMode.EXTERNAL,null,"测试司机","123456789",
new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400),List.of())), new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400),List.of())),
departure,null,null,sources,List.of()); departure,null,null,sources,List.of(), true);
} }
/** 通过实际两级流程进入装车,测试不直接伪造通过状态。 */ /** 通过实际两级流程进入装车,测试不直接伪造通过状态。 */
@@ -632,6 +632,6 @@ public class DispatchAggregatePersistenceTest {
} }
/** 测试建表按实际持久属性类型生成,正式DDL仍由独立主文档集成。 */ /** 测试建表按实际持久属性类型生成,正式DDL仍由独立主文档集成。 */
private static String type(Class<?> type) { return type==Long.class||type==long.class?"BIGINT":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(20000)"; } private static String type(Class<?> type) { return type==Long.class||type==long.class?"BIGINT":type==Boolean.class||type==boolean.class?"BOOLEAN":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(20000)"; }
} }
} }
@@ -40,6 +40,6 @@ class DispatchAppointmentAllocationsTest {
when(provider.candidateBatches(identities)).thenReturn(Map.of());assertThatThrownBy(()->service.candidates(List.of(a,b))).hasMessageContaining("批读范围不完整"); when(provider.candidateBatches(identities)).thenReturn(Map.of());assertThatThrownBy(()->service.candidates(List.of(a,b))).hasMessageContaining("批读范围不完整");
} }
private static com.baiyue.lastleg.dispatch.port.DispatchCommands.Create create(String key,List<BatchAllocation> allocations) { private static com.baiyue.lastleg.dispatch.port.DispatchCommands.Create create(String key,List<BatchAllocation> allocations) {
return new com.baiyue.lastleg.dispatch.port.DispatchCommands.Create(key,"9",null,null,null,null,null,List.of(),allocations); return new com.baiyue.lastleg.dispatch.port.DispatchCommands.Create(key,"9",null,null,null,null,null,List.of(),allocations, true);
} }
} }
@@ -0,0 +1,78 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
class DispatchCapacityDemandPersistenceTest {
/** 默认发布和取消发布随原排车命令同事务保存,原请求重放不重复修改。 */
@Test void defaultsAndUnpublishesThroughTheExistingVersionedCommand() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var request=f.json.readValue("{\"idempotencyKey\":\"pool-default\",\"warehouseId\":\"9\",\"cargo\":[],\"batchAllocations\":[]}",DispatchCommands.Create.class);
var created=f.tx.execute(s->f.service.create(f.actor,request));
var service=new DispatchCapacityDemandService(f.repository,f.json);
assertThat(service.page(query()).records()).extracting(DispatchCapacityDemands.Demand::dispatchNo).containsExactly(created.dispatchNo());
assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isTrue();
var update=new DispatchCommands.Update(created.recordVersion(),"pool-disable",null,null,null,null,null,false);
var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isFalse();
assertThat(service.page(query()).total()).isZero();
var replay=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
assertThat(replay.recordVersion()).isEqualTo(saved.recordVersion());
assertThat(service.page(query()).total()).isZero();
}
/** 余位缺测时保持未知;不能将实托数量当标准托位或使用默认三十托。 */
@Test void unknownCapacityIsVisibleAndNonDeliveryOrDepartedRowsAreExcluded() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var service=new DispatchCapacityDemandService(f.repository,f.json);
var created=f.tx.execute(s->f.service.create(f.actor,f.create("pool-unknown",List.of())));
var demand=service.page(query()).records().get(0);
assertThat(demand.capacityKnown()).isFalse();assertThat(demand.remainingPalletSpaces()).isNull();
assertThat(demand.remainingWeightKg()).isNull();assertThat(demand.capacityReason()).isNotBlank();
for(var type:List.of("PICKUP","LOCAL_TRUCK")) {
f.db.update("UPDATE ll_dispatch_order SET task_type=?",type);assertThat(service.page(query()).total()).isZero();
}
f.db.update("UPDATE ll_dispatch_order SET task_type='DELIVERY',stage='DELIVERING'");assertThat(service.page(query()).total()).isZero();
f.db.update("UPDATE ll_dispatch_order SET stage='BEFORE_LOADING',status='CANCELLED'");assertThat(service.page(query()).total()).isZero();
}
/** 真实装载去重后计算三维余位,已知满载在分页前排除。 */
@Test void computesConfirmedCapacityAndRemovesFullLoadsBeforePagination() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var service=new DispatchCapacityDemandService(f.repository,f.json);
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pool-known",List.of())));
var load=new ConfirmedLoad("test-manifest",List.of(new LoadingMeasurement(101L,3,new BigDecimal("400"),new BigDecimal("5"),1)),"operator-test",Instant.now());
f.db.update("UPDATE ll_dispatch_order SET loading_confirmation_json=?,stage='AFTER_LOADING',pallet_count=1",f.json.writeValueAsString(load));
var demand=service.page(query()).records().get(0);
assertThat(demand.capacityKnown()).isTrue();assertThat(demand.remainingPalletSpaces()).isEqualTo(12);
assertThat(demand.remainingWeightKg()).isEqualByComparingTo("9600");assertThat(demand.remainingVolumeCbm()).isEqualByComparingTo("45");
// 同一张实托占三个标准托位,不能按实托一件减一。
assertThat(demand.scheduledPalletCount()).isOne();
var full=new ConfirmedLoad("full",List.of(new LoadingMeasurement(101L,15,new BigDecimal("400"),new BigDecimal("5"),1)),"operator-test",Instant.now());
f.db.update("UPDATE ll_dispatch_order SET loading_confirmation_json=?",f.json.writeValueAsString(full));
assertThat(service.page(query()).total()).isZero();assertThat(service.page(query()).records()).isEmpty();
}
/** 重量或体积缺测不掩盖已明确的托位满载。 */
@Test void knownFullPalletSpacesHideEvenWithUnknownWeight() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
f.tx.execute(s->f.service.create(f.actor,f.configured("pool-full-slots",List.of())));
var load=new ConfirmedLoad("partial",List.of(new LoadingMeasurement(101L,15,null,null,1)),"operator-test",Instant.now());
f.db.update("UPDATE ll_dispatch_order SET loading_confirmation_json=?",f.json.writeValueAsString(load));
assertThat(new DispatchCapacityDemandService(f.repository,f.json).page(query()).total()).isZero();
}
/** 新建时可以显式取消默认发布,后续不产生额外发布记录。 */
@Test void createCanOptOutAndFiltersUseTheActualWarehouseAndRoute() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();var initial=f.create("pool-opt-out",List.of());
var request=new DispatchCommands.Create(initial.idempotencyKey(),initial.warehouseId(),null,null,null,null,null,List.of(),List.of(),false);
f.tx.execute(s->f.service.create(f.actor,request));
var service=new DispatchCapacityDemandService(f.repository,f.json);assertThat(service.page(query()).total()).isZero();
f.db.update("UPDATE ll_dispatch_order SET publish_looseboard_demand=TRUE,overseas_warehouse_name='测试仓',route='测试仓 → ONT8'");
assertThat(service.page(new DispatchCapacityDemands.Query(1,20,"测试","ONT8")).total()).isOne();
assertThat(service.page(new DispatchCapacityDemands.Query(1,20,"其它仓",null)).total()).isZero();
}
/** 使用与正式列表相同的分页输入。 */
private DispatchCapacityDemands.Query query() { return new DispatchCapacityDemands.Query(1,20,null,null); }
}
@@ -326,7 +326,7 @@ class DispatchDraftPersistenceTest {
@Test void conversionKeepsWholeSharedGroupAndOriginalCommandIdentity(){ @Test void conversionKeepsWholeSharedGroupAndOriginalCommandIdentity(){
relabelPair();var state=select(empty(1),0,1);var full=conversion(state,"convert-whole");var vehicle=full.groups().get(0);var create=vehicle.dispatch(); relabelPair();var state=select(empty(1),0,1);var full=conversion(state,"convert-whole");var vehicle=full.groups().get(0);var create=vehicle.dispatch();
var partial=new DispatchCommands.Create("partial-child",create.warehouseId(),create.vehicle(),create.driver(),create.plannedDepartureAt(), var partial=new DispatchCommands.Create("partial-child",create.warehouseId(),create.vehicle(),create.driver(),create.plannedDepartureAt(),
create.estimatedArrivalAt(),create.remark(),create.cargo().subList(0,1),create.batchAllocations()); create.estimatedArrivalAt(),create.remark(),create.cargo().subList(0,1),create.batchAllocations(), true);
assertThatThrownBy(()->service.convert(owner,new ConvertDraft(state.recordVersion(),"partial-convert",state.draftToken(),List.of(new DraftVehicle(vehicle.draftContainerId(),partial))))) assertThatThrownBy(()->service.convert(owner,new ConvertDraft(state.recordVersion(),"partial-convert",state.draftToken(),List.of(new DraftVehicle(vehicle.draftContainerId(),partial)))))
.isInstanceOf(BizException.class); .isInstanceOf(BizException.class);
assertThat(db.queryForObject("SELECT COUNT(*) FROM draft_created_dispatch",Integer.class)).isZero(); assertThat(db.queryForObject("SELECT COUNT(*) FROM draft_created_dispatch",Integer.class)).isZero();
@@ -368,7 +368,7 @@ class DispatchDraftPersistenceTest {
var state=select(empty(2),0,1);state=select(state,1,2);var original=conversion(state,"convert"); var state=select(empty(2),0,1);state=select(state,1,2);var original=conversion(state,"convert");
assertThatThrownBy(()->service.convert(owner,new ConvertDraft(original.recordVersion(),"partial",original.draftToken(),original.groups().subList(0,1)))).isInstanceOf(BizException.class); assertThatThrownBy(()->service.convert(owner,new ConvertDraft(original.recordVersion(),"partial",original.draftToken(),original.groups().subList(0,1)))).isInstanceOf(BizException.class);
var first=original.groups().get(0);var r=first.dispatch(); var first=original.groups().get(0);var r=first.dispatch();
var foreign=new DispatchCommands.Create(r.idempotencyKey(),"93",r.vehicle(),r.driver(),r.plannedDepartureAt(),r.estimatedArrivalAt(),r.remark(),r.cargo(),r.batchAllocations()); var foreign=new DispatchCommands.Create(r.idempotencyKey(),"93",r.vehicle(),r.driver(),r.plannedDepartureAt(),r.estimatedArrivalAt(),r.remark(),r.cargo(),r.batchAllocations(), true);
assertThatThrownBy(()->service.convert(owner,new ConvertDraft(original.recordVersion(),"foreign-wh",original.draftToken(),List.of(new DraftVehicle(first.draftContainerId(),foreign),original.groups().get(1))))).isInstanceOf(BizException.class); assertThatThrownBy(()->service.convert(owner,new ConvertDraft(original.recordVersion(),"foreign-wh",original.draftToken(),List.of(new DraftVehicle(first.draftContainerId(),foreign),original.groups().get(1))))).isInstanceOf(BizException.class);
clearInvocations(aggregate);versions.put(1L,"changed"); clearInvocations(aggregate);versions.put(1L,"changed");
assertThatThrownBy(()->service.convert(owner,original)).isInstanceOf(IllegalStateException.class); assertThatThrownBy(()->service.convert(owner,original)).isInstanceOf(IllegalStateException.class);
@@ -554,7 +554,7 @@ class DispatchDraftPersistenceTest {
} }
/** 每个非空服务器容器生成一组完整创建请求。 */ /** 每个非空服务器容器生成一组完整创建请求。 */
private ConvertDraft conversion(Draft draft,String key){var groups=new ArrayList<DraftVehicle>();int n=0;for(var c:draft.containers())if(!c.sourceKeys().isEmpty())groups.add(new DraftVehicle(c.draftContainerId(),new DispatchCommands.Create("vehicle-"+(++n),draft.warehouseId(),null,null,null,null,null, private ConvertDraft conversion(Draft draft,String key){var groups=new ArrayList<DraftVehicle>();int n=0;for(var c:draft.containers())if(!c.sourceKeys().isEmpty())groups.add(new DraftVehicle(c.draftContainerId(),new DispatchCommands.Create("vehicle-"+(++n),draft.warehouseId(),null,null,null,null,null,
draft.sources().stream().filter(s->c.sourceKeys().contains(s.sourceKey())).map(Candidate::selection).toList(),List.of())));return new ConvertDraft(draft.recordVersion(),key,draft.draftToken(),groups);} draft.sources().stream().filter(s->c.sourceKeys().contains(s.sourceKey())).map(Candidate::selection).toList(),List.of(), true)));return new ConvertDraft(draft.recordVersion(),key,draft.draftToken(),groups);}
/** 主单边界返回冻结的真实公开结果形状。 */ /** 主单边界返回冻结的真实公开结果形状。 */
private static CommandResult result(String no,String key,int cargo){return new CommandResult(no,Action.CREATE,key,0,Status.ACTIVE,Stage.BEFORE_LOADING,Instant.now(),Effect.APPLIED,new Counts(cargo,cargo,10L*cargo,0,0),new DispatchResults.Saved(no,List.of()),false,false);} private static CommandResult result(String no,String key,int cargo){return new CommandResult(no,Action.CREATE,key,0,Status.ACTIVE,Stage.BEFORE_LOADING,Instant.now(),Effect.APPLIED,new Counts(cargo,cargo,10L*cargo,0,0),new DispatchResults.Saved(no,List.of()),false,false);}
/** 测试独立命令键不依赖执行顺序或系统时间。 */ /** 测试独立命令键不依赖执行顺序或系统时间。 */
@@ -397,7 +397,7 @@ class DispatchRepairWorkflowTest {
} }
/** 使用当前公开整车创建DTO,不恢复旧客户预排接口。 */ /** 使用当前公开整车创建DTO,不恢复旧客户预排接口。 */
private DispatchCommands.Create create(List<DispatchCommands.CargoSelection> cargo) { private DispatchCommands.Create create(List<DispatchCommands.CargoSelection> cargo) {
return new DispatchCommands.Create(UUID.randomUUID().toString(),"9",null,null,null,null,null,cargo,List.of()); return new DispatchCommands.Create(UUID.randomUUID().toString(),"9",null,null,null,null,null,cargo,List.of(), true);
} }
/** 重放固定原版本及原键,取消旧单不会取得或释放新单占用。 */ /** 重放固定原版本及原键,取消旧单不会取得或释放新单占用。 */
private void cancel(String number) { private void cancel(String number) {
@@ -83,7 +83,7 @@ class DispatchResourcePhotosPersistenceTest {
var e = d.evidence(); var e = d.evidence();
var driver = new DispatchCommands.Driver(d.mode(), d.driverId(), d.name(), d.phone(), var driver = new DispatchCommands.Driver(d.mode(), d.driverId(), d.name(), d.phone(),
new DispatchContracts.DriverEvidence(e.licenseNo(), e.licenseExpiresOn(), e.fileIds(), e.expectedTaskEndAt(), photos)); new DispatchContracts.DriverEvidence(e.licenseNo(), e.licenseExpiresOn(), e.fileIds(), e.expectedTaskEndAt(), photos));
return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片"); return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片", true);
} }
/** 只替代外部附件提供者,资源、命令和事务仍走真实服务。 */ /** 只替代外部附件提供者,资源、命令和事务仍走真实服务。 */
@@ -88,7 +88,7 @@ class DispatchReviewWorkflowPersistenceTest {
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=3",Long.class); long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=3",Long.class);
var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,new DispatchCommands.ReasonCommand(addedAgain.recordVersion(),"remove","调整目的仓"))); var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,new DispatchCommands.ReasonCommand(addedAgain.recordVersion(),"remove","调整目的仓")));
var resources=f.configured("updated-resources",List.of()); var resources=f.configured("updated-resources",List.of());
var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正"))); var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正", true)));
var detail=f.service.detail(f.actor,created.dispatchNo()); var detail=f.service.detail(f.actor,created.dispatchNo());
var retained=loading?detail.reviews().loadingReview():detail.reviews().dispatchReview(); var retained=loading?detail.reviews().loadingReview():detail.reviews().dispatchReview();
assertThat(detail.reviewState()).isEqualTo(ReviewState.DRAFT); assertThat(detail.reviewState()).isEqualTo(ReviewState.DRAFT);
@@ -40,7 +40,7 @@ class OwnedBusinessScopePersistenceTest {
when(reader.detail(any(),anyString())).thenAnswer(call->{ when(reader.detail(any(),anyString())).thenAnswer(call->{
var s=baseline.repository.source(call.getArgument(1));var r=baseline.codec.read(s.getResourcesJson(),DispatchContracts.Resources.class); var s=baseline.repository.source(call.getArgument(1));var r=baseline.codec.read(s.getResourcesJson(),DispatchContracts.Resources.class);
return new DispatchViews.Detail(s.getDispatchNo(),"DELIVERY",DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(), return new DispatchViews.Detail(s.getDispatchNo(),"DELIVERY",DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(),
DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of()); DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of(),true);
}); });
queries=tx(new OwnedBusinessQueryService(baseline.repository,baseline.projection,baseline.codec,reader)); queries=tx(new OwnedBusinessQueryService(baseline.repository,baseline.projection,baseline.codec,reader));
var commands=tx(new OwnedBusinessCommandService(baseline.repository,baseline.codec)); var commands=tx(new OwnedBusinessCommandService(baseline.repository,baseline.codec));